aboutsummaryrefslogtreecommitdiff
path: root/src/main/kotlin/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/kotlin/features')
-rw-r--r--src/main/kotlin/features/FeatureManager.kt120
-rw-r--r--src/main/kotlin/features/FirmamentFeature.kt23
-rw-r--r--src/main/kotlin/features/chat/AutoCompletions.kt57
-rw-r--r--src/main/kotlin/features/chat/ChatLinks.kt161
-rw-r--r--src/main/kotlin/features/chat/QuickCommands.kt100
-rw-r--r--src/main/kotlin/features/debug/DebugLogger.kt13
-rw-r--r--src/main/kotlin/features/debug/DebugView.kt38
-rw-r--r--src/main/kotlin/features/debug/DeveloperFeatures.kt55
-rw-r--r--src/main/kotlin/features/debug/MinorTrolling.kt27
-rw-r--r--src/main/kotlin/features/debug/PowerUserTools.kt193
-rw-r--r--src/main/kotlin/features/diana/AncestralSpadeSolver.kt131
-rw-r--r--src/main/kotlin/features/diana/DianaWaypoints.kt35
-rw-r--r--src/main/kotlin/features/diana/NearbyBurrowsSolver.kt144
-rw-r--r--src/main/kotlin/features/events/anniversity/AnniversaryFeatures.kt224
-rw-r--r--src/main/kotlin/features/events/carnival/CarnivalFeatures.kt17
-rw-r--r--src/main/kotlin/features/events/carnival/MinesweeperHelper.kt276
-rw-r--r--src/main/kotlin/features/fixes/CompatibliltyFeatures.kt51
-rw-r--r--src/main/kotlin/features/fixes/Fixes.kt71
-rw-r--r--src/main/kotlin/features/inventory/CraftingOverlay.kt66
-rw-r--r--src/main/kotlin/features/inventory/ItemRarityCosmetics.kt85
-rw-r--r--src/main/kotlin/features/inventory/PriceData.kt51
-rw-r--r--src/main/kotlin/features/inventory/SaveCursorPosition.kt66
-rw-r--r--src/main/kotlin/features/inventory/SlotLocking.kt203
-rw-r--r--src/main/kotlin/features/inventory/buttons/InventoryButton.kt85
-rw-r--r--src/main/kotlin/features/inventory/buttons/InventoryButtonEditor.kt184
-rw-r--r--src/main/kotlin/features/inventory/buttons/InventoryButtonTemplates.kt35
-rw-r--r--src/main/kotlin/features/inventory/buttons/InventoryButtons.kt88
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StorageBackingHandle.kt53
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StorageData.kt21
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StorageOverlay.kt154
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StorageOverlayCustom.kt98
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StorageOverlayScreen.kt296
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StorageOverviewScreen.kt123
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/StoragePageSlot.kt66
-rw-r--r--src/main/kotlin/features/inventory/storageoverlay/VirtualInventory.kt65
-rw-r--r--src/main/kotlin/features/mining/Histogram.kt81
-rw-r--r--src/main/kotlin/features/mining/PickaxeAbility.kt176
-rw-r--r--src/main/kotlin/features/mining/PristineProfitTracker.kt133
-rw-r--r--src/main/kotlin/features/notifications/Notifications.kt7
-rw-r--r--src/main/kotlin/features/texturepack/AlwaysPredicate.kt17
-rw-r--r--src/main/kotlin/features/texturepack/AndPredicate.kt26
-rw-r--r--src/main/kotlin/features/texturepack/BakedModelExtra.kt9
-rw-r--r--src/main/kotlin/features/texturepack/BakedOverrideData.kt8
-rw-r--r--src/main/kotlin/features/texturepack/CustomBlockTextures.kt295
-rw-r--r--src/main/kotlin/features/texturepack/CustomGlobalArmorOverrides.kt106
-rw-r--r--src/main/kotlin/features/texturepack/CustomGlobalTextures.kt167
-rw-r--r--src/main/kotlin/features/texturepack/CustomModelOverrideParser.kt74
-rw-r--r--src/main/kotlin/features/texturepack/CustomSkyBlockTextures.kt114
-rw-r--r--src/main/kotlin/features/texturepack/DisplayNamePredicate.kt22
-rw-r--r--src/main/kotlin/features/texturepack/ExtraAttributesPredicate.kt268
-rw-r--r--src/main/kotlin/features/texturepack/FirmamentModelPredicate.kt8
-rw-r--r--src/main/kotlin/features/texturepack/FirmamentModelPredicateParser.kt8
-rw-r--r--src/main/kotlin/features/texturepack/ItemPredicate.kt32
-rw-r--r--src/main/kotlin/features/texturepack/JsonUnbakedModelFirmExtra.kt10
-rw-r--r--src/main/kotlin/features/texturepack/LorePredicate.kt19
-rw-r--r--src/main/kotlin/features/texturepack/ModelOverrideData.kt7
-rw-r--r--src/main/kotlin/features/texturepack/ModelOverrideFilterSet.kt19
-rw-r--r--src/main/kotlin/features/texturepack/NotPredicate.kt18
-rw-r--r--src/main/kotlin/features/texturepack/NumberMatcher.kt125
-rw-r--r--src/main/kotlin/features/texturepack/OrPredicate.kt26
-rw-r--r--src/main/kotlin/features/texturepack/PetPredicate.kt66
-rw-r--r--src/main/kotlin/features/texturepack/RarityMatcher.kt69
-rw-r--r--src/main/kotlin/features/texturepack/StringMatcher.kt159
-rw-r--r--src/main/kotlin/features/world/FairySouls.kt131
-rw-r--r--src/main/kotlin/features/world/NPCWaypoints.kt40
-rw-r--r--src/main/kotlin/features/world/NavigableWaypoint.kt22
-rw-r--r--src/main/kotlin/features/world/NavigationHelper.kt121
-rw-r--r--src/main/kotlin/features/world/NpcWaypointGui.kt68
-rw-r--r--src/main/kotlin/features/world/Waypoints.kt297
69 files changed, 6223 insertions, 0 deletions
diff --git a/src/main/kotlin/features/FeatureManager.kt b/src/main/kotlin/features/FeatureManager.kt
new file mode 100644
index 0000000..19b91de
--- /dev/null
+++ b/src/main/kotlin/features/FeatureManager.kt
@@ -0,0 +1,120 @@
+
+
+package moe.nea.firmament.features
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.serializer
+import moe.nea.firmament.Firmament
+import moe.nea.firmament.annotations.generated.AllSubscriptions
+import moe.nea.firmament.events.FeaturesInitializedEvent
+import moe.nea.firmament.events.FirmamentEvent
+import moe.nea.firmament.events.subscription.Subscription
+import moe.nea.firmament.features.chat.AutoCompletions
+import moe.nea.firmament.features.chat.ChatLinks
+import moe.nea.firmament.features.chat.QuickCommands
+import moe.nea.firmament.features.debug.DebugView
+import moe.nea.firmament.features.debug.DeveloperFeatures
+import moe.nea.firmament.features.debug.MinorTrolling
+import moe.nea.firmament.features.debug.PowerUserTools
+import moe.nea.firmament.features.diana.DianaWaypoints
+import moe.nea.firmament.features.events.anniversity.AnniversaryFeatures
+import moe.nea.firmament.features.events.carnival.CarnivalFeatures
+import moe.nea.firmament.features.fixes.CompatibliltyFeatures
+import moe.nea.firmament.features.fixes.Fixes
+import moe.nea.firmament.features.inventory.CraftingOverlay
+import moe.nea.firmament.features.inventory.ItemRarityCosmetics
+import moe.nea.firmament.features.inventory.PriceData
+import moe.nea.firmament.features.inventory.SaveCursorPosition
+import moe.nea.firmament.features.inventory.SlotLocking
+import moe.nea.firmament.features.inventory.buttons.InventoryButtons
+import moe.nea.firmament.features.inventory.storageoverlay.StorageOverlay
+import moe.nea.firmament.features.mining.PickaxeAbility
+import moe.nea.firmament.features.mining.PristineProfitTracker
+import moe.nea.firmament.features.texturepack.CustomSkyBlockTextures
+import moe.nea.firmament.features.world.FairySouls
+import moe.nea.firmament.features.world.Waypoints
+import moe.nea.firmament.util.data.DataHolder
+
+object FeatureManager : DataHolder<FeatureManager.Config>(serializer(), "features", ::Config) {
+ @Serializable
+ data class Config(
+ val enabledFeatures: MutableMap<String, Boolean> = mutableMapOf()
+ )
+
+ private val features = mutableMapOf<String, FirmamentFeature>()
+
+ val allFeatures: Collection<FirmamentFeature> get() = features.values
+
+ private var hasAutoloaded = false
+
+ init {
+ autoload()
+ }
+
+ fun autoload() {
+ synchronized(this) {
+ if (hasAutoloaded) return
+ loadFeature(MinorTrolling)
+ loadFeature(FairySouls)
+ loadFeature(AutoCompletions)
+ // TODO: loadFeature(FishingWarning)
+ loadFeature(SlotLocking)
+ loadFeature(StorageOverlay)
+ loadFeature(PristineProfitTracker)
+ loadFeature(CraftingOverlay)
+ loadFeature(PowerUserTools)
+ loadFeature(Waypoints)
+ loadFeature(ChatLinks)
+ loadFeature(InventoryButtons)
+ loadFeature(CompatibliltyFeatures)
+ loadFeature(AnniversaryFeatures)
+ loadFeature(QuickCommands)
+ loadFeature(SaveCursorPosition)
+ loadFeature(CustomSkyBlockTextures)
+ loadFeature(PriceData)
+ loadFeature(Fixes)
+ loadFeature(DianaWaypoints)
+ loadFeature(ItemRarityCosmetics)
+ loadFeature(PickaxeAbility)
+ loadFeature(CarnivalFeatures)
+ if (Firmament.DEBUG) {
+ loadFeature(DeveloperFeatures)
+ loadFeature(DebugView)
+ }
+ allFeatures.forEach { it.config }
+ FeaturesInitializedEvent.publish(FeaturesInitializedEvent(allFeatures.toList()))
+ hasAutoloaded = true
+ }
+ }
+
+ fun subscribeEvents() {
+ AllSubscriptions.provideSubscriptions {
+ subscribeSingleEvent(it)
+ }
+ }
+
+ private fun <T : FirmamentEvent> subscribeSingleEvent(it: Subscription<T>) {
+ it.eventBus.subscribe(false, it.invoke)
+ }
+
+ fun loadFeature(feature: FirmamentFeature) {
+ synchronized(features) {
+ if (feature.identifier in features) {
+ Firmament.logger.error("Double registering feature ${feature.identifier}. Ignoring second instance $feature")
+ return
+ }
+ features[feature.identifier] = feature
+ feature.onLoad()
+ }
+ }
+
+ fun isEnabled(identifier: String): Boolean? =
+ data.enabledFeatures[identifier]
+
+
+ fun setEnabled(identifier: String, value: Boolean) {
+ data.enabledFeatures[identifier] = value
+ markDirty()
+ }
+
+}
diff --git a/src/main/kotlin/features/FirmamentFeature.kt b/src/main/kotlin/features/FirmamentFeature.kt
new file mode 100644
index 0000000..2cfc4fd
--- /dev/null
+++ b/src/main/kotlin/features/FirmamentFeature.kt
@@ -0,0 +1,23 @@
+
+
+package moe.nea.firmament.features
+
+import moe.nea.firmament.events.subscription.SubscriptionOwner
+import moe.nea.firmament.gui.config.ManagedConfig
+
+// TODO: remove this entire feature system and revamp config
+interface FirmamentFeature : SubscriptionOwner {
+ val identifier: String
+ val defaultEnabled: Boolean
+ get() = true
+ var isEnabled: Boolean
+ get() = FeatureManager.isEnabled(identifier) ?: defaultEnabled
+ set(value) {
+ FeatureManager.setEnabled(identifier, value)
+ }
+ override val delegateFeature: FirmamentFeature
+ get() = this
+ val config: ManagedConfig? get() = null
+ fun onLoad() {}
+
+}
diff --git a/src/main/kotlin/features/chat/AutoCompletions.kt b/src/main/kotlin/features/chat/AutoCompletions.kt
new file mode 100644
index 0000000..9144898
--- /dev/null
+++ b/src/main/kotlin/features/chat/AutoCompletions.kt
@@ -0,0 +1,57 @@
+
+
+package moe.nea.firmament.features.chat
+
+import com.mojang.brigadier.arguments.StringArgumentType.string
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.commands.get
+import moe.nea.firmament.commands.suggestsList
+import moe.nea.firmament.commands.thenArgument
+import moe.nea.firmament.commands.thenExecute
+import moe.nea.firmament.events.CommandEvent
+import moe.nea.firmament.events.MaskCommands
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.repo.RepoManager
+import moe.nea.firmament.util.MC
+
+object AutoCompletions : FirmamentFeature {
+
+ object TConfig : ManagedConfig(identifier) {
+ val provideWarpTabCompletion by toggle("warp-complete") { true }
+ val replaceWarpIsByWarpIsland by toggle("warp-is") { true }
+ }
+
+ override val config: ManagedConfig?
+ get() = TConfig
+ override val identifier: String
+ get() = "auto-completions"
+
+ @Subscribe
+ fun onMaskCommands(event: MaskCommands) {
+ if (TConfig.provideWarpTabCompletion) {
+ event.mask("warp")
+ }
+ }
+
+ @Subscribe
+ fun onCommandEvent(event: CommandEvent) {
+ if (!TConfig.provideWarpTabCompletion) return
+ event.deleteCommand("warp")
+ event.register("warp") {
+ thenArgument("to", string()) { toArg ->
+ suggestsList {
+ RepoManager.neuRepo.constants?.islands?.warps?.flatMap { listOf(it.warp) + it.aliases } ?: listOf()
+ }
+ thenExecute {
+ val warpName = get(toArg)
+ if (warpName == "is" && TConfig.replaceWarpIsByWarpIsland) {
+ MC.sendServerCommand("warp island")
+ } else {
+ MC.sendServerCommand("warp $warpName")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/features/chat/ChatLinks.kt b/src/main/kotlin/features/chat/ChatLinks.kt
new file mode 100644
index 0000000..f2cb78a
--- /dev/null
+++ b/src/main/kotlin/features/chat/ChatLinks.kt
@@ -0,0 +1,161 @@
+
+
+package moe.nea.firmament.features.chat
+
+import io.ktor.client.request.get
+import io.ktor.client.statement.bodyAsChannel
+import io.ktor.utils.io.jvm.javaio.toInputStream
+import java.net.URL
+import java.util.Collections
+import moe.nea.jarvis.api.Point
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlin.math.min
+import net.minecraft.client.gui.screen.ChatScreen
+import net.minecraft.client.texture.NativeImage
+import net.minecraft.client.texture.NativeImageBackedTexture
+import net.minecraft.text.ClickEvent
+import net.minecraft.text.HoverEvent
+import net.minecraft.text.Style
+import net.minecraft.text.Text
+import net.minecraft.util.Formatting
+import net.minecraft.util.Identifier
+import moe.nea.firmament.Firmament
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.ModifyChatEvent
+import moe.nea.firmament.events.ScreenRenderPostEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.transformEachRecursively
+import moe.nea.firmament.util.unformattedString
+
+object ChatLinks : FirmamentFeature {
+ override val identifier: String
+ get() = "chat-links"
+
+ object TConfig : ManagedConfig(identifier) {
+ val enableLinks by toggle("links-enabled") { true }
+ val imageEnabled by toggle("image-enabled") { true }
+ val allowAllHosts by toggle("allow-all-hosts") { false }
+ val allowedHosts by string("allowed-hosts") { "cdn.discordapp.com,media.discordapp.com,media.discordapp.net,i.imgur.com" }
+ val actualAllowedHosts get() = allowedHosts.split(",").map { it.trim() }
+ val position by position("position", 16 * 20, 9 * 20) { Point(0.0, 0.0) }
+ }
+
+ private fun isHostAllowed(host: String) =
+ TConfig.allowAllHosts || TConfig.actualAllowedHosts.any { it.equals(host, ignoreCase = true) }
+
+ private fun isUrlAllowed(url: String) = isHostAllowed(url.removePrefix("https://").substringBefore("/"))
+
+ override val config get() = TConfig
+ val urlRegex = "https://[^. ]+\\.[^ ]+(\\.?( |$))".toRegex()
+
+ data class Image(
+ val texture: Identifier,
+ val width: Int,
+ val height: Int,
+ )
+
+ val imageCache: MutableMap<String, Deferred<Image?>> =
+ Collections.synchronizedMap(mutableMapOf<String, Deferred<Image?>>())
+
+ private fun tryCacheUrl(url: String) {
+ if (!isUrlAllowed(url)) {
+ return
+ }
+ if (url in imageCache) {
+ return
+ }
+ imageCache[url] = Firmament.coroutineScope.async {
+ try {
+ val response = Firmament.httpClient.get(URL(url))
+ if (response.status.value == 200) {
+ val inputStream = response.bodyAsChannel().toInputStream(Firmament.globalJob)
+ val image = NativeImage.read(inputStream)
+ val texture = MC.textureManager.registerDynamicTexture(
+ "dynamic_image_preview",
+ NativeImageBackedTexture(image)
+ )
+ Image(texture, image.width, image.height)
+ } else
+ null
+ } catch (exc: Exception) {
+ exc.printStackTrace()
+ null
+ }
+ }
+ }
+
+ val imageExtensions = listOf("jpg", "png", "gif", "jpeg")
+ fun isImageUrl(url: String): Boolean {
+ return (url.substringAfterLast('.').lowercase() in imageExtensions)
+ }
+
+ @Subscribe
+ @OptIn(ExperimentalCoroutinesApi::class)
+ fun onRender(it: ScreenRenderPostEvent) {
+ if (!TConfig.imageEnabled) return
+ if (it.screen !is ChatScreen) return
+ val hoveredComponent =
+ MC.inGameHud.chatHud.getTextStyleAt(it.mouseX.toDouble(), it.mouseY.toDouble()) ?: return
+ val hoverEvent = hoveredComponent.hoverEvent ?: return
+ val value = hoverEvent.getValue(HoverEvent.Action.SHOW_TEXT) ?: return
+ val url = urlRegex.matchEntire(value.unformattedString)?.groupValues?.get(0) ?: return
+ if (!isImageUrl(url)) return
+ val imageFuture = imageCache[url] ?: return
+ if (!imageFuture.isCompleted) return
+ val image = imageFuture.getCompleted() ?: return
+ it.drawContext.matrices.push()
+ val pos = TConfig.position
+ pos.applyTransformations(it.drawContext.matrices)
+ val scale = min(1F, min((9 * 20F) / image.height, (16 * 20F) / image.width))
+ it.drawContext.matrices.scale(scale, scale, 1F)
+ it.drawContext.drawTexture(
+ image.texture,
+ 0,
+ 0,
+ 1F,
+ 1F,
+ image.width,
+ image.height,
+ image.width,
+ image.height,
+ )
+ it.drawContext.matrices.pop()
+ }
+
+ @Subscribe
+ fun onModifyChat(it: ModifyChatEvent) {
+ if (!TConfig.enableLinks) return
+ it.replaceWith = it.replaceWith.transformEachRecursively { child ->
+ val text = child.string
+ if ("://" !in text) return@transformEachRecursively child
+ val s = Text.empty().setStyle(child.style)
+ var index = 0
+ while (index < text.length) {
+ val nextMatch = urlRegex.find(text, index)
+ if (nextMatch == null) {
+ s.append(Text.literal(text.substring(index, text.length)))
+ break
+ }
+ val range = nextMatch.groups[0]!!.range
+ val url = nextMatch.groupValues[0]
+ s.append(Text.literal(text.substring(index, range.first)))
+ s.append(
+ Text.literal(url).setStyle(
+ Style.EMPTY.withUnderline(true).withColor(
+ Formatting.AQUA
+ ).withHoverEvent(HoverEvent(HoverEvent.Action.SHOW_TEXT, Text.literal(url)))
+ .withClickEvent(ClickEvent(ClickEvent.Action.OPEN_URL, url))
+ )
+ )
+ if (isImageUrl(url))
+ tryCacheUrl(url)
+ index = range.last + 1
+ }
+ s
+ }
+ }
+}
diff --git a/src/main/kotlin/features/chat/QuickCommands.kt b/src/main/kotlin/features/chat/QuickCommands.kt
new file mode 100644
index 0000000..5944b92
--- /dev/null
+++ b/src/main/kotlin/features/chat/QuickCommands.kt
@@ -0,0 +1,100 @@
+
+
+package moe.nea.firmament.features.chat
+
+import com.mojang.brigadier.context.CommandContext
+import net.minecraft.text.Text
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.commands.DefaultSource
+import moe.nea.firmament.commands.RestArgumentType
+import moe.nea.firmament.commands.get
+import moe.nea.firmament.commands.thenArgument
+import moe.nea.firmament.commands.thenExecute
+import moe.nea.firmament.events.CommandEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.SBData
+
+object QuickCommands : FirmamentFeature {
+ override val identifier: String
+ get() = "quick-commands"
+
+ fun removePartialPrefix(text: String, prefix: String): String? {
+ var lf: String? = null
+ for (i in 1..prefix.length) {
+ if (text.startsWith(prefix.substring(0, i))) {
+ lf = text.substring(i)
+ }
+ }
+ return lf
+ }
+
+ val kuudraLevelNames = listOf("NORMAL", "HOT", "BURNING", "FIERY", "INFERNAL")
+ val dungeonLevelNames = listOf("ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN")
+
+ @Subscribe
+ fun onCommands(it: CommandEvent) {
+ it.register("join") {
+ thenArgument("what", RestArgumentType) { what ->
+ thenExecute {
+ val what = this[what]
+ if (!SBData.isOnSkyblock) {
+ MC.sendCommand("join $what")
+ return@thenExecute
+ }
+ val joinName = getNameForFloor(what.replace(" ", "").lowercase())
+ if (joinName == null) {
+ source.sendFeedback(Text.stringifiedTranslatable("firmament.quick-commands.join.unknown", what))
+ } else {
+ source.sendFeedback(Text.stringifiedTranslatable("firmament.quick-commands.join.success",
+ joinName))
+ MC.sendCommand("joininstance $joinName")
+ }
+ }
+ }
+ thenExecute {
+ source.sendFeedback(Text.translatable("firmament.quick-commands.join.explain"))
+ }
+ }
+ }
+
+ fun CommandContext<DefaultSource>.getNameForFloor(w: String): String? {
+ val kuudraLevel = removePartialPrefix(w, "kuudratier") ?: removePartialPrefix(w, "tier")
+ if (kuudraLevel != null) {
+ val l = kuudraLevel.toIntOrNull()?.let { it - 1 } ?: kuudraLevelNames.indexOfFirst {
+ it.startsWith(
+ kuudraLevel,
+ true
+ )
+ }
+ if (l !in kuudraLevelNames.indices) {
+ source.sendFeedback(Text.stringifiedTranslatable("firmament.quick-commands.join.unknown-kuudra",
+ kuudraLevel))
+ return null
+ }
+ return "KUUDRA_${kuudraLevelNames[l]}"
+ }
+ val masterLevel = removePartialPrefix(w, "master")
+ val normalLevel =
+ removePartialPrefix(w, "floor") ?: removePartialPrefix(w, "catacombs") ?: removePartialPrefix(w, "dungeons")
+ val dungeonLevel = masterLevel ?: normalLevel
+ if (dungeonLevel != null) {
+ val l = dungeonLevel.toIntOrNull()?.let { it - 1 } ?: dungeonLevelNames.indexOfFirst {
+ it.startsWith(
+ dungeonLevel,
+ true
+ )
+ }
+ if (masterLevel == null && (l == -1 || null != removePartialPrefix(w, "entrance"))) {
+ return "CATACOMBS_ENTRANCE"
+ }
+ if (l !in dungeonLevelNames.indices) {
+ source.sendFeedback(Text.stringifiedTranslatable("firmament.quick-commands.join.unknown-catacombs",
+ kuudraLevel))
+ return null
+ }
+ return "${if (masterLevel != null) "MASTER_" else ""}CATACOMBS_FLOOR_${dungeonLevelNames[l]}"
+ }
+ return null
+ }
+}
diff --git a/src/main/kotlin/features/debug/DebugLogger.kt b/src/main/kotlin/features/debug/DebugLogger.kt
new file mode 100644
index 0000000..ab06030
--- /dev/null
+++ b/src/main/kotlin/features/debug/DebugLogger.kt
@@ -0,0 +1,13 @@
+
+package moe.nea.firmament.features.debug
+
+import net.minecraft.text.Text
+import moe.nea.firmament.util.MC
+
+class DebugLogger(val tag: String) {
+ fun isEnabled() = DeveloperFeatures.isEnabled // TODO: allow filtering by tag
+ fun log(text: () -> String) {
+ if (!isEnabled()) return
+ MC.sendChat(Text.literal(text()))
+ }
+}
diff --git a/src/main/kotlin/features/debug/DebugView.kt b/src/main/kotlin/features/debug/DebugView.kt
new file mode 100644
index 0000000..7e1b8ec
--- /dev/null
+++ b/src/main/kotlin/features/debug/DebugView.kt
@@ -0,0 +1,38 @@
+
+
+package moe.nea.firmament.features.debug
+
+import moe.nea.firmament.Firmament
+import moe.nea.firmament.events.TickEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.util.TimeMark
+
+object DebugView : FirmamentFeature {
+ private data class StoredVariable<T>(
+ val obj: T,
+ val timer: TimeMark,
+ )
+
+ private val storedVariables: MutableMap<String, StoredVariable<*>> = sortedMapOf()
+ override val identifier: String
+ get() = "debug-view"
+ override val defaultEnabled: Boolean
+ get() = Firmament.DEBUG
+
+ fun <T : Any?> showVariable(label: String, obj: T) {
+ synchronized(this) {
+ storedVariables[label] = StoredVariable(obj, TimeMark.now())
+ }
+ }
+
+ fun recalculateDebugWidget() {
+ }
+
+ override fun onLoad() {
+ TickEvent.subscribe {
+ synchronized(this) {
+ recalculateDebugWidget()
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/features/debug/DeveloperFeatures.kt b/src/main/kotlin/features/debug/DeveloperFeatures.kt
new file mode 100644
index 0000000..20c0cfd
--- /dev/null
+++ b/src/main/kotlin/features/debug/DeveloperFeatures.kt
@@ -0,0 +1,55 @@
+
+
+package moe.nea.firmament.features.debug
+
+import java.nio.file.Path
+import java.util.concurrent.CompletableFuture
+import kotlin.io.path.absolute
+import kotlin.io.path.exists
+import net.minecraft.client.MinecraftClient
+import net.minecraft.text.Text
+import moe.nea.firmament.Firmament
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.TimeMark
+import moe.nea.firmament.util.errorBoundary
+import moe.nea.firmament.util.iterate
+
+object DeveloperFeatures : FirmamentFeature {
+ override val identifier: String
+ get() = "developer"
+ override val config: TConfig
+ get() = TConfig
+ override val defaultEnabled: Boolean
+ get() = Firmament.DEBUG
+
+ val gradleDir =
+ Path.of(".").absolute()
+ .iterate { it.parent }
+ .find { it.resolve("settings.gradle.kts").exists() }
+
+ object TConfig : ManagedConfig("developer") {
+ val autoRebuildResources by toggle("auto-rebuild") { false }
+ }
+
+ @JvmStatic
+ fun hookOnBeforeResourceReload(client: MinecraftClient): CompletableFuture<Void> {
+ val reloadFuture = if (TConfig.autoRebuildResources && isEnabled && gradleDir != null) {
+ val builder = ProcessBuilder("./gradlew", ":processResources")
+ builder.directory(gradleDir.toFile())
+ builder.inheritIO()
+ val process = builder.start()
+ MC.player?.sendMessage(Text.translatable("firmament.dev.resourcerebuild.start"))
+ val startTime = TimeMark.now()
+ process.toHandle().onExit().thenApply {
+ MC.player?.sendMessage(Text.stringifiedTranslatable("firmament.dev.resourcerebuild.done", startTime.passedTime()))
+ Unit
+ }
+ } else {
+ CompletableFuture.completedFuture(Unit)
+ }
+ return reloadFuture.thenCompose { client.reloadResources() }
+ }
+}
+
diff --git a/src/main/kotlin/features/debug/MinorTrolling.kt b/src/main/kotlin/features/debug/MinorTrolling.kt
new file mode 100644
index 0000000..32035a6
--- /dev/null
+++ b/src/main/kotlin/features/debug/MinorTrolling.kt
@@ -0,0 +1,27 @@
+
+
+package moe.nea.firmament.features.debug
+
+import net.minecraft.text.Text
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.ModifyChatEvent
+import moe.nea.firmament.features.FirmamentFeature
+
+
+// In memorian Dulkir
+object MinorTrolling : FirmamentFeature {
+ override val identifier: String
+ get() = "minor-trolling"
+
+ val trollers = listOf("nea89o", "lrg89")
+ val t = "From(?: \\[[^\\]]+])? ([^:]+): (.*)".toRegex()
+
+ @Subscribe
+ fun onTroll(it: ModifyChatEvent) {
+ val m = t.matchEntire(it.unformattedString) ?: return
+ val (_, name, text) = m.groupValues
+ if (name !in trollers) return
+ if (!text.startsWith("c:")) return
+ it.replaceWith = Text.literal(text.substring(2).replace("&", "§"))
+ }
+}
diff --git a/src/main/kotlin/features/debug/PowerUserTools.kt b/src/main/kotlin/features/debug/PowerUserTools.kt
new file mode 100644
index 0000000..7893eff
--- /dev/null
+++ b/src/main/kotlin/features/debug/PowerUserTools.kt
@@ -0,0 +1,193 @@
+
+
+package moe.nea.firmament.features.debug
+
+import net.minecraft.block.SkullBlock
+import net.minecraft.block.entity.SkullBlockEntity
+import net.minecraft.component.DataComponentTypes
+import net.minecraft.entity.Entity
+import net.minecraft.entity.LivingEntity
+import net.minecraft.item.ItemStack
+import net.minecraft.item.Items
+import net.minecraft.text.Text
+import net.minecraft.util.hit.BlockHitResult
+import net.minecraft.util.hit.EntityHitResult
+import net.minecraft.util.hit.HitResult
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.CommandEvent
+import moe.nea.firmament.events.CustomItemModelEvent
+import moe.nea.firmament.events.HandledScreenKeyPressedEvent
+import moe.nea.firmament.events.ItemTooltipEvent
+import moe.nea.firmament.events.ScreenChangeEvent
+import moe.nea.firmament.events.TickEvent
+import moe.nea.firmament.events.WorldKeyboardEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.features.texturepack.CustomSkyBlockTextures
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.mixins.accessor.AccessorHandledScreen
+import moe.nea.firmament.util.ClipboardUtils
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.focusedItemStack
+import moe.nea.firmament.util.skyBlockId
+
+object PowerUserTools : FirmamentFeature {
+ override val identifier: String
+ get() = "power-user"
+
+ object TConfig : ManagedConfig(identifier) {
+ val showItemIds by toggle("show-item-id") { false }
+ val copyItemId by keyBindingWithDefaultUnbound("copy-item-id")
+ val copyTexturePackId by keyBindingWithDefaultUnbound("copy-texture-pack-id")
+ val copyNbtData by keyBindingWithDefaultUnbound("copy-nbt-data")
+ val copySkullTexture by keyBindingWithDefaultUnbound("copy-skull-texture")
+ val copyEntityData by keyBindingWithDefaultUnbound("entity-data")
+ }
+
+ override val config
+ get() = TConfig
+
+ var lastCopiedStack: Pair<ItemStack, Text>? = null
+ set(value) {
+ field = value
+ if (value != null)
+ lastCopiedStackViewTime = true
+ }
+ var lastCopiedStackViewTime = false
+
+ override fun onLoad() {
+ TickEvent.subscribe {
+ if (!lastCopiedStackViewTime)
+ lastCopiedStack = null
+ lastCopiedStackViewTime = false
+ }
+ ScreenChangeEvent.subscribe {
+ lastCopiedStack = null
+ }
+ }
+
+ fun debugFormat(itemStack: ItemStack): Text {
+ return Text.literal(itemStack.skyBlockId?.toString() ?: itemStack.toString())
+ }
+
+ @Subscribe
+ fun onEntityInfo(event: WorldKeyboardEvent) {
+ if (!event.matches(TConfig.copyEntityData)) return
+ val target = (MC.instance.crosshairTarget as? EntityHitResult)?.entity
+ if (target == null) {
+ MC.sendChat(Text.translatable("firmament.poweruser.entity.fail"))
+ return
+ }
+ showEntity(target)
+ }
+
+ fun showEntity(target: Entity) {
+ MC.sendChat(Text.translatable("firmament.poweruser.entity.type", target.type))
+ MC.sendChat(Text.translatable("firmament.poweruser.entity.name", target.name))
+ MC.sendChat(Text.stringifiedTranslatable("firmament.poweruser.entity.position", target.pos))
+ if (target is LivingEntity) {
+ MC.sendChat(Text.translatable("firmament.poweruser.entity.armor"))
+ for (armorItem in target.armorItems) {
+ MC.sendChat(Text.translatable("firmament.poweruser.entity.armor.item", debugFormat(armorItem)))
+ }
+ }
+ MC.sendChat(Text.stringifiedTranslatable("firmament.poweruser.entity.passengers", target.passengerList.size))
+ target.passengerList.forEach {
+ showEntity(it)
+ }
+ }
+
+
+ @Subscribe
+ fun copyInventoryInfo(it: HandledScreenKeyPressedEvent) {
+ if (it.screen !is AccessorHandledScreen) return
+ val item = it.screen.focusedItemStack ?: return
+ if (it.matches(TConfig.copyItemId)) {
+ val sbId = item.skyBlockId
+ if (sbId == null) {
+ lastCopiedStack = Pair(item, Text.translatable("firmament.tooltip.copied.skyblockid.fail"))
+ return
+ }
+ ClipboardUtils.setTextContent(sbId.neuItem)
+ lastCopiedStack =
+ Pair(item, Text.stringifiedTranslatable("firmament.tooltip.copied.skyblockid", sbId.neuItem))
+ } else if (it.matches(TConfig.copyTexturePackId)) {
+ val model = CustomItemModelEvent.getModelIdentifier(item)
+ if (model == null) {
+ lastCopiedStack = Pair(item, Text.translatable("firmament.tooltip.copied.modelid.fail"))
+ return
+ }
+ ClipboardUtils.setTextContent(model.toString())
+ lastCopiedStack =
+ Pair(item, Text.stringifiedTranslatable("firmament.tooltip.copied.modelid", model.toString()))
+ } else if (it.matches(TConfig.copyNbtData)) {
+ // TODO: copy full nbt
+ val nbt = item.get(DataComponentTypes.CUSTOM_DATA)?.nbt?.toString() ?: "<empty>"
+ ClipboardUtils.setTextContent(nbt)
+ lastCopiedStack = Pair(item, Text.translatable("firmament.tooltip.copied.nbt"))
+ } else if (it.matches(TConfig.copySkullTexture)) {
+ if (item.item != Items.PLAYER_HEAD) {
+ lastCopiedStack = Pair(item, Text.translatable("firmament.tooltip.copied.skull-id.fail.no-skull"))
+ return
+ }
+ val profile = item.get(DataComponentTypes.PROFILE)
+ if (profile == null) {
+ lastCopiedStack = Pair(item, Text.translatable("firmament.tooltip.copied.skull-id.fail.no-profile"))
+ return
+ }
+ val skullTexture = CustomSkyBlockTextures.getSkullTexture(profile)
+ if (skullTexture == null) {
+ lastCopiedStack = Pair(item, Text.translatable("firmament.tooltip.copied.skull-id.fail.no-texture"))
+ return
+ }
+ ClipboardUtils.setTextContent(skullTexture.toString())
+ lastCopiedStack =
+ Pair(
+ item,
+ Text.stringifiedTranslatable("firmament.tooltip.copied.skull-id", skullTexture.toString())
+ )
+ println("Copied skull id: $skullTexture")
+ }
+ }
+
+ @Subscribe
+ fun onCopyWorldInfo(it: WorldKeyboardEvent) {
+ if (it.matches(TConfig.copySkullTexture)) {
+ val p = MC.camera ?: return
+ val blockHit = p.raycast(20.0, 0.0f, false) ?: return
+ if (blockHit.type != HitResult.Type.BLOCK || blockHit !is BlockHitResult) {
+ MC.sendChat(Text.translatable("firmament.tooltip.copied.skull.fail"))
+ return
+ }
+ val blockAt = p.world.getBlockState(blockHit.blockPos)?.block
+ val entity = p.world.getBlockEntity(blockHit.blockPos)
+ if (blockAt !is SkullBlock || entity !is SkullBlockEntity || entity.owner == null) {
+ MC.sendChat(Text.translatable("firmament.tooltip.copied.skull.fail"))
+ return
+ }
+ val id = CustomSkyBlockTextures.getSkullTexture(entity.owner!!)
+ if (id == null) {
+ MC.sendChat(Text.translatable("firmament.tooltip.copied.skull.fail"))
+ } else {
+ ClipboardUtils.setTextContent(id.toString())
+ MC.sendChat(Text.stringifiedTranslatable("firmament.tooltip.copied.skull", id.toString()))
+ }
+ }
+ }
+
+ @Subscribe
+ fun addItemId(it: ItemTooltipEvent) {
+ if (TConfig.showItemIds) {
+ val id = it.stack.skyBlockId ?: return
+ it.lines.add(Text.stringifiedTranslatable("firmament.tooltip.skyblockid", id.neuItem))
+ }
+ val (item, text) = lastCopiedStack ?: return
+ if (!ItemStack.areEqual(item, it.stack)) {
+ lastCopiedStack = null
+ return
+ }
+ lastCopiedStackViewTime = true
+ it.lines.add(text)
+ }
+
+
+}
diff --git a/src/main/kotlin/features/diana/AncestralSpadeSolver.kt b/src/main/kotlin/features/diana/AncestralSpadeSolver.kt
new file mode 100644
index 0000000..39ca6d3
--- /dev/null
+++ b/src/main/kotlin/features/diana/AncestralSpadeSolver.kt
@@ -0,0 +1,131 @@
+
+package moe.nea.firmament.features.diana
+
+import kotlin.time.Duration.Companion.seconds
+import net.minecraft.particle.ParticleTypes
+import net.minecraft.sound.SoundEvents
+import net.minecraft.util.math.Vec3d
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.ParticleSpawnEvent
+import moe.nea.firmament.events.SoundReceiveEvent
+import moe.nea.firmament.events.WorldKeyboardEvent
+import moe.nea.firmament.events.WorldReadyEvent
+import moe.nea.firmament.events.WorldRenderLastEvent
+import moe.nea.firmament.events.subscription.SubscriptionOwner
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.SBData
+import moe.nea.firmament.util.SkyBlockIsland
+import moe.nea.firmament.util.SkyblockId
+import moe.nea.firmament.util.TimeMark
+import moe.nea.firmament.util.WarpUtil
+import moe.nea.firmament.util.render.RenderInWorldContext
+import moe.nea.firmament.util.skyBlockId
+
+object AncestralSpadeSolver : SubscriptionOwner {
+ var lastDing = TimeMark.farPast()
+ private set
+ private val pitches = mutableListOf<Float>()
+ val particlePositions = mutableListOf<Vec3d>()
+ var nextGuess: Vec3d? = null
+ private set
+
+ val ancestralSpadeId = SkyblockId("ANCESTRAL_SPADE")
+ private var lastTeleportAttempt = TimeMark.farPast()
+
+ fun isEnabled() =
+ DianaWaypoints.TConfig.ancestralSpadeSolver
+ && SBData.skyblockLocation == SkyBlockIsland.HUB
+ && MC.player?.inventory?.containsAny { it.skyBlockId == ancestralSpadeId } == true // TODO: add a reactive property here
+
+ @Subscribe
+ fun onKeyBind(event: WorldKeyboardEvent) {
+ if (!isEnabled()) return
+ if (!event.matches(DianaWaypoints.TConfig.ancestralSpadeTeleport)) return
+
+ if (lastTeleportAttempt.passedTime() < 3.seconds) return
+ WarpUtil.teleportToNearestWarp(SkyBlockIsland.HUB, nextGuess ?: return)
+ lastTeleportAttempt = TimeMark.now()
+ }
+
+ @Subscribe
+ fun onParticleSpawn(event: ParticleSpawnEvent) {
+ if (!isEnabled()) return
+ if (event.particleEffect != ParticleTypes.DRIPPING_LAVA) return
+ if (event.offset.x != 0.0F || event.offset.y != 0F || event.offset.z != 0F)
+ return
+ particlePositions.add(event.position)
+ if (particlePositions.size > 20) {
+ particlePositions.removeFirst()
+ }
+ }
+
+ @Subscribe
+ fun onPlaySound(event: SoundReceiveEvent) {
+ if (!isEnabled()) return
+ if (!SoundEvents.BLOCK_NOTE_BLOCK_HARP.matchesId(event.sound.value().id)) return
+
+ if (lastDing.passedTime() > 1.seconds) {
+ particlePositions.clear()
+ pitches.clear()
+ }
+ lastDing = TimeMark.now()
+
+ pitches.add(event.pitch)
+ if (pitches.size > 20) {
+ pitches.removeFirst()
+ }
+
+ if (particlePositions.size < 3) {
+ return
+ }
+
+ val averagePitchDelta =
+ if (pitches.isEmpty()) return
+ else pitches
+ .zipWithNext { a, b -> b - a }
+ .average()
+
+ val soundDistanceEstimate = (Math.E / averagePitchDelta) - particlePositions.first().distanceTo(event.position)
+
+ if (soundDistanceEstimate > 1000) {
+ return
+ }
+
+ val lastParticleDirection = particlePositions
+ .takeLast(3)
+ .let { (a, _, b) -> b.subtract(a) }
+ .normalize()
+
+ nextGuess = event.position.add(lastParticleDirection.multiply(soundDistanceEstimate))
+ }
+
+ @Subscribe
+ fun onWorldRender(event: WorldRenderLastEvent) {
+ if (!isEnabled()) return
+ RenderInWorldContext.renderInWorld(event) {
+ nextGuess?.let {
+ color(1f, 1f, 0f, 0.5f)
+ tinyBlock(it, 1f)
+ color(1f, 1f, 0f, 1f)
+ tracer(it, lineWidth = 3f)
+ }
+ if (particlePositions.size > 2 && lastDing.passedTime() < 10.seconds && nextGuess != null) {
+ color(0f, 1f, 0f, 0.7f)
+ line(particlePositions)
+ }
+ }
+ }
+
+ @Subscribe
+ fun onSwapWorld(event: WorldReadyEvent) {
+ nextGuess = null
+ particlePositions.clear()
+ pitches.clear()
+ lastDing = TimeMark.farPast()
+ }
+
+ override val delegateFeature: FirmamentFeature
+ get() = DianaWaypoints
+
+}
diff --git a/src/main/kotlin/features/diana/DianaWaypoints.kt b/src/main/kotlin/features/diana/DianaWaypoints.kt
new file mode 100644
index 0000000..0a34eaa
--- /dev/null
+++ b/src/main/kotlin/features/diana/DianaWaypoints.kt
@@ -0,0 +1,35 @@
+
+package moe.nea.firmament.features.diana
+
+import moe.nea.firmament.events.AttackBlockEvent
+import moe.nea.firmament.events.ParticleSpawnEvent
+import moe.nea.firmament.events.ProcessChatEvent
+import moe.nea.firmament.events.SoundReceiveEvent
+import moe.nea.firmament.events.UseBlockEvent
+import moe.nea.firmament.events.WorldKeyboardEvent
+import moe.nea.firmament.events.WorldReadyEvent
+import moe.nea.firmament.events.WorldRenderLastEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+
+object DianaWaypoints : FirmamentFeature {
+ override val identifier get() = "diana"
+ override val config get() = TConfig
+
+ object TConfig : ManagedConfig(identifier) {
+ val ancestralSpadeSolver by toggle("ancestral-spade") { true }
+ val ancestralSpadeTeleport by keyBindingWithDefaultUnbound("ancestral-teleport")
+ val nearbyWaypoints by toggle("nearby-waypoints") { true }
+ }
+
+ override fun onLoad() {
+ UseBlockEvent.subscribe {
+ NearbyBurrowsSolver.onBlockClick(it.hitResult.blockPos)
+ }
+ AttackBlockEvent.subscribe {
+ NearbyBurrowsSolver.onBlockClick(it.blockPos)
+ }
+ }
+}
+
+
diff --git a/src/main/kotlin/features/diana/NearbyBurrowsSolver.kt b/src/main/kotlin/features/diana/NearbyBurrowsSolver.kt
new file mode 100644
index 0000000..7158bb9
--- /dev/null
+++ b/src/main/kotlin/features/diana/NearbyBurrowsSolver.kt
@@ -0,0 +1,144 @@
+
+package moe.nea.firmament.features.diana
+
+import kotlin.time.Duration.Companion.seconds
+import net.minecraft.particle.ParticleTypes
+import net.minecraft.util.math.BlockPos
+import net.minecraft.util.math.MathHelper
+import net.minecraft.util.math.Position
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.ParticleSpawnEvent
+import moe.nea.firmament.events.ProcessChatEvent
+import moe.nea.firmament.events.WorldReadyEvent
+import moe.nea.firmament.events.WorldRenderLastEvent
+import moe.nea.firmament.events.subscription.SubscriptionOwner
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.util.TimeMark
+import moe.nea.firmament.util.mutableMapWithMaxSize
+import moe.nea.firmament.util.render.RenderInWorldContext.Companion.renderInWorld
+
+object NearbyBurrowsSolver : SubscriptionOwner {
+
+
+ private val recentlyDugBurrows: MutableMap<BlockPos, TimeMark> = mutableMapWithMaxSize(20)
+ private val recentEnchantParticles: MutableMap<BlockPos, TimeMark> = mutableMapWithMaxSize(500)
+ private var lastBlockClick: BlockPos? = null
+
+ enum class BurrowType {
+ START, MOB, TREASURE
+ }
+
+ val burrows = mutableMapOf<BlockPos, BurrowType>()
+
+ @Subscribe
+ fun onChatEvent(event: ProcessChatEvent) {
+ val lastClickedBurrow = lastBlockClick ?: return
+ if (event.unformattedString.startsWith("You dug out a Griffin Burrow!") ||
+ event.unformattedString.startsWith(" ☠ You were killed by") ||
+ event.unformattedString.startsWith("You finished the Griffin burrow chain!")
+ ) {
+ markAsDug(lastClickedBurrow)
+ burrows.remove(lastClickedBurrow)
+ }
+ }
+
+
+ fun wasRecentlyDug(blockPos: BlockPos): Boolean {
+ val lastDigTime = recentlyDugBurrows[blockPos] ?: TimeMark.farPast()
+ return lastDigTime.passedTime() < 10.seconds
+ }
+
+ fun markAsDug(blockPos: BlockPos) {
+ recentlyDugBurrows[blockPos] = TimeMark.now()
+ }
+
+ fun wasRecentlyEnchanted(blockPos: BlockPos): Boolean {
+ val lastEnchantTime = recentEnchantParticles[blockPos] ?: TimeMark.farPast()
+ return lastEnchantTime.passedTime() < 4.seconds
+ }
+
+ fun markAsEnchanted(blockPos: BlockPos) {
+ recentEnchantParticles[blockPos] = TimeMark.now()
+ }
+
+ @Subscribe
+ fun onParticles(event: ParticleSpawnEvent) {
+ if (!DianaWaypoints.TConfig.nearbyWaypoints) return
+
+ val position: BlockPos = event.position.toBlockPos().down()
+
+ if (wasRecentlyDug(position)) return
+
+ val isEven50Spread = (event.offset.x == 0.5f && event.offset.z == 0.5f)
+
+ if (event.particleEffect.type == ParticleTypes.ENCHANT) {
+ if (event.count == 5 && event.speed == 0.05F && event.offset.y == 0.4F && isEven50Spread) {
+ markAsEnchanted(position)
+ }
+ return
+ }
+
+ if (!wasRecentlyEnchanted(position)) return
+
+ if (event.particleEffect.type == ParticleTypes.ENCHANTED_HIT
+ && event.count == 4
+ && event.speed == 0.01F
+ && event.offset.y == 0.1f
+ && isEven50Spread
+ ) {
+ burrows[position] = BurrowType.START
+ }
+ if (event.particleEffect.type == ParticleTypes.CRIT
+ && event.count == 3
+ && event.speed == 0.01F
+ && event.offset.y == 0.1F
+ && isEven50Spread
+ ) {
+ burrows[position] = BurrowType.MOB
+ }
+ if (event.particleEffect.type == ParticleTypes.DRIPPING_LAVA
+ && event.count == 2
+ && event.speed == 0.01F
+ && event.offset.y == 0.1F
+ && event.offset.x == 0.35F && event.offset.z == 0.35f
+ ) {
+ burrows[position] = BurrowType.TREASURE
+ }
+ }
+
+ @Subscribe
+ fun onRender(event: WorldRenderLastEvent) {
+ if (!DianaWaypoints.TConfig.nearbyWaypoints) return
+ renderInWorld(event) {
+ for ((location, burrow) in burrows) {
+ when (burrow) {
+ BurrowType.START -> color(.2f, .8f, .2f, 0.4f)
+ BurrowType.MOB -> color(0.3f, 0.4f, 0.9f, 0.4f)
+ BurrowType.TREASURE -> color(1f, 0.7f, 0.2f, 0.4f)
+ }
+ block(location)
+ }
+ }
+ }
+
+ @Subscribe
+ fun onSwapWorld(worldReadyEvent: WorldReadyEvent) {
+ burrows.clear()
+ recentEnchantParticles.clear()
+ recentlyDugBurrows.clear()
+ lastBlockClick = null
+ }
+
+ fun onBlockClick(blockPos: BlockPos) {
+ if (!DianaWaypoints.TConfig.nearbyWaypoints) return
+ burrows.remove(blockPos)
+ lastBlockClick = blockPos
+ }
+
+ override val delegateFeature: FirmamentFeature
+ get() = DianaWaypoints
+}
+
+fun Position.toBlockPos(): BlockPos {
+ return BlockPos(MathHelper.floor(x), MathHelper.floor(y), MathHelper.floor(z))
+}
diff --git a/src/main/kotlin/features/events/anniversity/AnniversaryFeatures.kt b/src/main/kotlin/features/events/anniversity/AnniversaryFeatures.kt
new file mode 100644
index 0000000..8926a95
--- /dev/null
+++ b/src/main/kotlin/features/events/anniversity/AnniversaryFeatures.kt
@@ -0,0 +1,224 @@
+
+package moe.nea.firmament.features.events.anniversity
+
+import io.github.notenoughupdates.moulconfig.observer.ObservableList
+import io.github.notenoughupdates.moulconfig.xml.Bind
+import moe.nea.jarvis.api.Point
+import kotlin.time.Duration.Companion.seconds
+import net.minecraft.entity.passive.PigEntity
+import net.minecraft.util.math.BlockPos
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.EntityInteractionEvent
+import moe.nea.firmament.events.ProcessChatEvent
+import moe.nea.firmament.events.TickEvent
+import moe.nea.firmament.events.WorldReadyEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.gui.hud.MoulConfigHud
+import moe.nea.firmament.rei.SBItemEntryDefinition
+import moe.nea.firmament.repo.ItemNameLookup
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.SHORT_NUMBER_FORMAT
+import moe.nea.firmament.util.SkyblockId
+import moe.nea.firmament.util.TimeMark
+import moe.nea.firmament.util.parseShortNumber
+import moe.nea.firmament.util.useMatch
+
+object AnniversaryFeatures : FirmamentFeature {
+ override val identifier: String
+ get() = "anniversary"
+
+ object TConfig : ManagedConfig(identifier) {
+ val enableShinyPigTracker by toggle("shiny-pigs") {true}
+ val trackPigCooldown by position("pig-hud", 200, 300) { Point(0.1, 0.2) }
+ }
+
+ override val config: ManagedConfig?
+ get() = TConfig
+
+ data class ClickedPig(
+ val clickedAt: TimeMark,
+ val startLocation: BlockPos,
+ val pigEntity: PigEntity
+ ) {
+ @Bind("timeLeft")
+ fun getTimeLeft(): Double = 1 - clickedAt.passedTime() / pigDuration
+ }
+
+ val clickedPigs = ObservableList<ClickedPig>(mutableListOf())
+ var lastClickedPig: PigEntity? = null
+
+ val pigDuration = 90.seconds
+
+ @Subscribe
+ fun onTick(event: TickEvent) {
+ clickedPigs.removeIf { it.clickedAt.passedTime() > pigDuration }
+ }
+
+ val pattern = "SHINY! You extracted (?<reward>.*) from the piglet's orb!".toPattern()
+
+ @Subscribe
+ fun onChat(event: ProcessChatEvent) {
+ if(!TConfig.enableShinyPigTracker)return
+ if (event.unformattedString == "Oink! Bring the pig back to the Shiny Orb!") {
+ val pig = lastClickedPig ?: return
+ // TODO: store proper location based on the orb location, maybe
+ val startLocation = pig.blockPos ?: return
+ clickedPigs.add(ClickedPig(TimeMark.now(), startLocation, pig))
+ lastClickedPig = null
+ }
+ if (event.unformattedString == "SHINY! The orb is charged! Click on it for loot!") {
+ val player = MC.player ?: return
+ val lowest =
+ clickedPigs.minByOrNull { it.startLocation.getSquaredDistance(player.pos) } ?: return
+ clickedPigs.remove(lowest)
+ }
+ pattern.useMatch(event.unformattedString) {
+ val reward = group("reward")
+ val parsedReward = parseReward(reward)
+ addReward(parsedReward)
+ PigCooldown.rewards.atOnce {
+ PigCooldown.rewards.clear()
+ rewards.mapTo(PigCooldown.rewards) { PigCooldown.DisplayReward(it) }
+ }
+ }
+ }
+
+ fun addReward(reward: Reward) {
+ val it = rewards.listIterator()
+ while (it.hasNext()) {
+ val merged = reward.mergeWith(it.next()) ?: continue
+ it.set(merged)
+ return
+ }
+ rewards.add(reward)
+ }
+
+ val rewards = mutableListOf<Reward>()
+
+ fun <T> ObservableList<T>.atOnce(block: () -> Unit) {
+ val oldObserver = observer
+ observer = null
+ block()
+ observer = oldObserver
+ update()
+ }
+
+ sealed interface Reward {
+ fun mergeWith(other: Reward): Reward?
+ data class EXP(val amount: Double, val skill: String) : Reward {
+ override fun mergeWith(other: Reward): Reward? {
+ if (other is EXP && other.skill == skill)
+ return EXP(amount + other.amount, skill)
+ return null
+ }
+ }
+
+ data class Coins(val amount: Double) : Reward {
+ override fun mergeWith(other: Reward): Reward? {
+ if (other is Coins)
+ return Coins(other.amount + amount)
+ return null
+ }
+ }
+
+ data class Items(val amount: Int, val item: SkyblockId) : Reward {
+ override fun mergeWith(other: Reward): Reward? {
+ if (other is Items && other.item == item)
+ return Items(amount + other.amount, item)
+ return null
+ }
+ }
+
+ data class Unknown(val text: String) : Reward {
+ override fun mergeWith(other: Reward): Reward? {
+ return null
+ }
+ }
+ }
+
+ val expReward = "\\+(?<exp>$SHORT_NUMBER_FORMAT) (?<kind>[^ ]+) XP".toPattern()
+ val coinReward = "\\+(?<amount>$SHORT_NUMBER_FORMAT) coins".toPattern()
+ val itemReward = "(?:(?<amount>[0-9]+)x )?(?<name>.*)".toPattern()
+ fun parseReward(string: String): Reward {
+ expReward.useMatch<Unit>(string) {
+ val exp = parseShortNumber(group("exp"))
+ val kind = group("kind")
+ return Reward.EXP(exp, kind)
+ }
+ coinReward.useMatch<Unit>(string) {
+ val coins = parseShortNumber(group("amount"))
+ return Reward.Coins(coins)
+ }
+ itemReward.useMatch(string) {
+ val amount = group("amount")?.toIntOrNull() ?: 1
+ val name = group("name")
+ val item = ItemNameLookup.guessItemByName(name, false) ?: return@useMatch
+ return Reward.Items(amount, item)
+ }
+ return Reward.Unknown(string)
+ }
+
+ @Subscribe
+ fun onWorldClear(event: WorldReadyEvent) {
+ lastClickedPig = null
+ clickedPigs.clear()
+ }
+
+ @Subscribe
+ fun onEntityClick(event: EntityInteractionEvent) {
+ if (event.entity is PigEntity) {
+ lastClickedPig = event.entity
+ }
+ }
+
+ @Subscribe
+ fun init(event: WorldReadyEvent) {
+ PigCooldown.forceInit()
+ }
+
+ object PigCooldown : MoulConfigHud("anniversary_pig", TConfig.trackPigCooldown) {
+ override fun shouldRender(): Boolean {
+ return clickedPigs.isNotEmpty() && TConfig.enableShinyPigTracker
+ }
+
+ @Bind("pigs")
+ fun getPigs() = clickedPigs
+
+ class DisplayReward(val backedBy: Reward) {
+ @Bind
+ fun count(): String {
+ return when (backedBy) {
+ is Reward.Coins -> backedBy.amount
+ is Reward.EXP -> backedBy.amount
+ is Reward.Items -> backedBy.amount
+ is Reward.Unknown -> 0
+ }.toString()
+ }
+
+ val itemStack = if (backedBy is Reward.Items) {
+ SBItemEntryDefinition.getEntry(backedBy.item, backedBy.amount)
+ } else {
+ SBItemEntryDefinition.getEntry(SkyblockId.NULL)
+ }
+
+ @Bind
+ fun name(): String {
+ return when (backedBy) {
+ is Reward.Coins -> "Coins"
+ is Reward.EXP -> backedBy.skill
+ is Reward.Items -> itemStack.value.asItemStack().name.string
+ is Reward.Unknown -> backedBy.text
+ }
+ }
+
+ @Bind
+ fun isKnown() = backedBy !is Reward.Unknown
+ }
+
+ @get:Bind("rewards")
+ val rewards = ObservableList<DisplayReward>(mutableListOf())
+
+ }
+
+}
diff --git a/src/main/kotlin/features/events/carnival/CarnivalFeatures.kt b/src/main/kotlin/features/events/carnival/CarnivalFeatures.kt
new file mode 100644
index 0000000..1e6d97a
--- /dev/null
+++ b/src/main/kotlin/features/events/carnival/CarnivalFeatures.kt
@@ -0,0 +1,17 @@
+
+package moe.nea.firmament.features.events.carnival
+
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+
+object CarnivalFeatures : FirmamentFeature {
+ object TConfig : ManagedConfig(identifier) {
+ val enableBombSolver by toggle("bombs-solver") { true }
+ val displayTutorials by toggle("tutorials") { true }
+ }
+
+ override val config: ManagedConfig?
+ get() = TConfig
+ override val identifier: String
+ get() = "carnival"
+}
diff --git a/src/main/kotlin/features/events/carnival/MinesweeperHelper.kt b/src/main/kotlin/features/events/carnival/MinesweeperHelper.kt
new file mode 100644
index 0000000..06caf86
--- /dev/null
+++ b/src/main/kotlin/features/events/carnival/MinesweeperHelper.kt
@@ -0,0 +1,276 @@
+
+package moe.nea.firmament.features.events.carnival
+
+import io.github.notenoughupdates.moulconfig.observer.ObservableList
+import io.github.notenoughupdates.moulconfig.platform.ModernItemStack
+import io.github.notenoughupdates.moulconfig.xml.Bind
+import java.util.UUID
+import net.minecraft.block.Blocks
+import net.minecraft.item.Item
+import net.minecraft.item.ItemStack
+import net.minecraft.item.Items
+import net.minecraft.text.ClickEvent
+import net.minecraft.text.Text
+import net.minecraft.util.math.BlockPos
+import net.minecraft.world.WorldAccess
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.commands.thenExecute
+import moe.nea.firmament.events.AttackBlockEvent
+import moe.nea.firmament.events.CommandEvent
+import moe.nea.firmament.events.EntityUpdateEvent
+import moe.nea.firmament.events.ProcessChatEvent
+import moe.nea.firmament.events.WorldReadyEvent
+import moe.nea.firmament.events.WorldRenderLastEvent
+import moe.nea.firmament.features.debug.DebugLogger
+import moe.nea.firmament.util.LegacyFormattingCode
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.MoulConfigUtils
+import moe.nea.firmament.util.ScreenUtil
+import moe.nea.firmament.util.SkyblockId
+import moe.nea.firmament.util.item.createSkullItem
+import moe.nea.firmament.util.render.RenderInWorldContext
+import moe.nea.firmament.util.setSkyBlockFirmamentUiId
+import moe.nea.firmament.util.skyBlockId
+import moe.nea.firmament.util.useMatch
+
+object MinesweeperHelper {
+ val sandBoxLow = BlockPos(-112, 72, -11)
+ val sandBoxHigh = BlockPos(-106, 72, -5)
+ val boardSize = Pair(sandBoxHigh.x - sandBoxLow.x, sandBoxHigh.z - sandBoxLow.z)
+
+ val gameStartMessage = "[NPC] Carnival Pirateman: Good luck, matey!"
+ val gameEndMessage = "Fruit Digging"
+ val bombPattern = "MINES! There (are|is) (?<bombCount>[0-8]) bombs? hidden nearby\\.".toPattern()
+ val startGameQuestion = "[NPC] Carnival Pirateman: Would ye like to do some Fruit Digging?"
+
+
+ enum class Piece(
+ @get:Bind("fruitName")
+ val fruitName: String,
+ val points: Int,
+ val specialAbility: String,
+ val totalPerBoard: Int,
+ val textureHash: String,
+ val fruitColor: LegacyFormattingCode,
+ ) {
+ COCONUT("Coconut",
+ 200,
+ "Prevents a bomb from exploding next turn",
+ 3,
+ "10ceb1455b471d016a9f06d25f6e468df9fcf223e2c1e4795b16e84fcca264ee",
+ LegacyFormattingCode.DARK_PURPLE),
+ APPLE("Apple",
+ 100,
+ "Gains 100 points for each apple dug up",
+ 8,
+ "17ea278d6225c447c5943d652798d0bbbd1418434ce8c54c54fdac79994ddd6c",
+ LegacyFormattingCode.GREEN),
+ WATERMELON("Watermelon",
+ 100,
+ "Blows up an adjacent fruit for half the points",
+ 4,
+ "efe4ef83baf105e8dee6cf03dfe7407f1911b3b9952c891ae34139560f2931d6",
+ LegacyFormattingCode.DARK_BLUE),
+ DURIAN("Durian",
+ 800,
+ "Halves the points earned in the next turn",
+ 2,
+ "ac268d36c2c6047ffeec00124096376b56dbb4d756a55329363a1b27fcd659cd",
+ LegacyFormattingCode.DARK_PURPLE),
+ MANGO("Mango",
+ 300,
+ "Just an ordinary fruit",
+ 10,
+ "f363a62126a35537f8189343a22660de75e810c6ac004a7d3da65f1c040a839",
+ LegacyFormattingCode.GREEN),
+ DRAGON_FRUIT("Dragonfruit",
+ 1200,
+ "Halves the points earned in the next turn",
+ 1,
+ "3cc761bcb0579763d9b8ab6b7b96fa77eb6d9605a804d838fec39e7b25f95591",
+ LegacyFormattingCode.LIGHT_PURPLE),
+ POMEGRANATE("Pomegranate",
+ 200,
+ "Grants an extra 50% more points in the next turn",
+ 4,
+ "40824d18079042d5769f264f44394b95b9b99ce689688cc10c9eec3f882ccc08",
+ LegacyFormattingCode.DARK_BLUE),
+ CHERRY("Cherry",
+ 200,
+ "The second cherry grants 300 bonus points",
+ 2,
+ "c92b099a62cd2fbf8ada09dec145c75d7fda4dc57b968bea3a8fa11e37aa48b2",
+ LegacyFormattingCode.DARK_PURPLE),
+ BOMB("Bomb",
+ -1,
+ "Destroys nearby fruit",
+ 15,
+ "a76a2811d1e176a07b6d0a657b910f134896ce30850f6e80c7c83732d85381ea",
+ LegacyFormattingCode.DARK_RED),
+ RUM("Rum",
+ -1,
+ "Stops your dowsing ability for one turn",
+ 5,
+ "407b275d28b927b1bf7f6dd9f45fbdad2af8571c54c8f027d1bff6956fbf3c16",
+ LegacyFormattingCode.YELLOW),
+ ;
+
+ val textureUrl = "http://textures.minecraft.net/texture/$textureHash"
+ val itemStack = createSkullItem(UUID.randomUUID(), textureUrl)
+ .setSkyBlockFirmamentUiId("MINESWEEPER_$name")
+
+ @Bind
+ fun getIcon() = ModernItemStack.of(itemStack)
+
+ @Bind
+ fun pieceLabel() = fruitColor.formattingCode + fruitName
+
+ @Bind
+ fun boardLabel() = "§a$totalPerBoard§7/§rboard"
+
+ @Bind("description")
+ fun getDescription() = buildString {
+ append(specialAbility)
+ if (points >= 0) {
+ append(" Default points: $points.")
+ }
+ }
+ }
+
+ object TutorialScreen {
+ @get:Bind("pieces")
+ val pieces = ObservableList(Piece.entries.toList().reversed())
+
+ @get:Bind("modes")
+ val modes = ObservableList(DowsingMode.entries.toList())
+ }
+
+ enum class DowsingMode(
+ val itemType: Item,
+ @get:Bind("feature")
+ val feature: String,
+ @get:Bind("description")
+ val description: String,
+ ) {
+ MINES(Items.IRON_SHOVEL, "Bomb detection", "Tells you how many bombs are near the block"),
+ ANCHOR(Items.DIAMOND_SHOVEL, "Lowest fruit", "Shows you which block nearby contains the lowest scoring fruit"),
+ TREASURE(Items.GOLDEN_SHOVEL, "Highest fruit", "Tells you which kind of fruit is the highest scoring nearby"),
+ ;
+
+ @Bind("itemType")
+ fun getItemStack() = ModernItemStack.of(ItemStack(itemType))
+
+ companion object {
+ val id = SkyblockId("CARNIVAL_SHOVEL")
+ fun fromItem(itemStack: ItemStack): DowsingMode? {
+ if (itemStack.skyBlockId != id) return null
+ return DowsingMode.entries.find { it.itemType == itemStack.item }
+ }
+ }
+ }
+
+ data class BoardPosition(
+ val x: Int,
+ val y: Int
+ ) {
+ fun toBlockPos() = BlockPos(sandBoxLow.x + x, sandBoxLow.y, sandBoxLow.z + y)
+
+ fun getBlock(world: WorldAccess) = world.getBlockState(toBlockPos()).block
+ fun isUnopened(world: WorldAccess) = getBlock(world) == Blocks.SAND
+ fun isOpened(world: WorldAccess) = getBlock(world) == Blocks.SANDSTONE
+ fun isScorched(world: WorldAccess) = getBlock(world) == Blocks.SANDSTONE_STAIRS
+
+ companion object {
+ fun fromBlockPos(blockPos: BlockPos): BoardPosition? {
+ if (blockPos.y != sandBoxLow.y) return null
+ val x = blockPos.x - sandBoxLow.x
+ val y = blockPos.z - sandBoxLow.z
+ if (x < 0 || x >= boardSize.first) return null
+ if (y < 0 || y >= boardSize.second) return null
+ return BoardPosition(x, y)
+ }
+ }
+ }
+
+ data class GameState(
+ val nearbyBombs: MutableMap<BoardPosition, Int> = mutableMapOf(),
+ val knownBombPositions: MutableSet<BoardPosition> = mutableSetOf(),
+ var lastClickedPosition: BoardPosition? = null,
+ var lastDowsingMode: DowsingMode? = null,
+ )
+
+ var gameState: GameState? = null
+ val log = DebugLogger("minesweeper")
+
+ @Subscribe
+ fun onCommand(event: CommandEvent.SubCommand) {
+ event.subcommand("minesweepertutorial") {
+ thenExecute {
+ ScreenUtil.setScreenLater(MoulConfigUtils.loadScreen("carnival/minesweeper_tutorial",
+ TutorialScreen,
+ null))
+ }
+ }
+ }
+
+ @Subscribe
+ fun onWorldChange(event: WorldReadyEvent) {
+ gameState = null
+ }
+
+ @Subscribe
+ fun onChat(event: ProcessChatEvent) {
+ if (CarnivalFeatures.TConfig.displayTutorials && event.unformattedString == startGameQuestion) {
+ MC.sendChat(Text.translatable("firmament.carnival.tutorial.minesweeper").styled {
+ it.withClickEvent(ClickEvent(ClickEvent.Action.RUN_COMMAND, "/firm minesweepertutorial"))
+ })
+ }
+ if (!CarnivalFeatures.TConfig.enableBombSolver) {
+ gameState = null // TODO: replace this which a watchable property
+ return
+ }
+ if (event.unformattedString == gameStartMessage) {
+ gameState = GameState()
+ log.log { "Game started" }
+ }
+ if (event.unformattedString.trim() == gameEndMessage) {
+ gameState = null // TODO: add a loot tracker maybe? probably not, i dont think people care
+ log.log { "Finished game" }
+ }
+ val gs = gameState ?: return
+ bombPattern.useMatch(event.unformattedString) {
+ val bombCount = group("bombCount").toInt()
+ log.log { "Marking ${gs.lastClickedPosition} as having $bombCount nearby" }
+ val pos = gs.lastClickedPosition ?: return
+ gs.nearbyBombs[pos] = bombCount
+ }
+ }
+
+ @Subscribe
+ fun onMobChange(event: EntityUpdateEvent) {
+ val gs = gameState ?: return
+ if (event !is EntityUpdateEvent.TrackedDataUpdate) return
+ // TODO: listen to state
+ }
+
+ @Subscribe
+ fun onBlockClick(event: AttackBlockEvent) {
+ val gs = gameState ?: return
+ val boardPosition = BoardPosition.fromBlockPos(event.blockPos)
+ log.log { "Breaking block at ${event.blockPos} ($boardPosition)" }
+ gs.lastClickedPosition = boardPosition
+ gs.lastDowsingMode = DowsingMode.fromItem(event.player.inventory.mainHandStack)
+ }
+
+ @Subscribe
+ fun onRender(event: WorldRenderLastEvent) {
+ val gs = gameState ?: return
+ RenderInWorldContext.renderInWorld(event) {
+ for ((pos, bombCount) in gs.nearbyBombs) {
+ this.text(pos.toBlockPos().up().toCenterPos(), Text.literal("§a$bombCount \uD83D\uDCA3"))
+ }
+ }
+ }
+
+
+}
diff --git a/src/main/kotlin/features/fixes/CompatibliltyFeatures.kt b/src/main/kotlin/features/fixes/CompatibliltyFeatures.kt
new file mode 100644
index 0000000..7c43cf6
--- /dev/null
+++ b/src/main/kotlin/features/fixes/CompatibliltyFeatures.kt
@@ -0,0 +1,51 @@
+
+
+package moe.nea.firmament.features.fixes
+
+import net.fabricmc.loader.api.FabricLoader
+import net.superkat.explosiveenhancement.api.ExplosiveApi
+import net.minecraft.particle.ParticleTypes
+import net.minecraft.util.math.Vec3d
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.ParticleSpawnEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.MC
+
+object CompatibliltyFeatures : FirmamentFeature {
+ override val identifier: String
+ get() = "compatibility"
+
+ object TConfig : ManagedConfig(identifier) {
+ val enhancedExplosions by toggle("explosion-enabled") { false }
+ val explosionSize by integer("explosion-power", 10, 50) { 1 }
+ }
+
+ override val config: ManagedConfig?
+ get() = TConfig
+
+ interface ExplosiveApiWrapper {
+ fun spawnParticle(vec3d: Vec3d, power: Float)
+ }
+
+ class ExplosiveApiWrapperImpl : ExplosiveApiWrapper {
+ override fun spawnParticle(vec3d: Vec3d, power: Float) {
+ ExplosiveApi.spawnParticles(MC.world, vec3d.x, vec3d.y, vec3d.z, TConfig.explosionSize / 10F)
+ }
+ }
+
+ val explosiveApiWrapper = if (FabricLoader.getInstance().isModLoaded("explosiveenhancement")) {
+ ExplosiveApiWrapperImpl()
+ } else null
+
+ @Subscribe
+ fun onExplosion(it: ParticleSpawnEvent) {
+ if (TConfig.enhancedExplosions &&
+ it.particleEffect.type == ParticleTypes.EXPLOSION_EMITTER &&
+ explosiveApiWrapper != null
+ ) {
+ it.cancel()
+ explosiveApiWrapper.spawnParticle(it.position, 2F)
+ }
+ }
+}
diff --git a/src/main/kotlin/features/fixes/Fixes.kt b/src/main/kotlin/features/fixes/Fixes.kt
new file mode 100644
index 0000000..d7b7a1c
--- /dev/null
+++ b/src/main/kotlin/features/fixes/Fixes.kt
@@ -0,0 +1,71 @@
+
+
+package moe.nea.firmament.features.fixes
+
+import moe.nea.jarvis.api.Point
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable
+import net.minecraft.client.MinecraftClient
+import net.minecraft.client.option.KeyBinding
+import net.minecraft.entity.player.PlayerEntity
+import net.minecraft.text.Text
+import net.minecraft.util.Arm
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.HudRenderEvent
+import moe.nea.firmament.events.WorldKeyboardEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.errorBoundary
+
+object Fixes : FirmamentFeature {
+ override val identifier: String
+ get() = "fixes"
+
+ object TConfig : ManagedConfig(identifier) {
+ val fixUnsignedPlayerSkins by toggle("player-skins") { true }
+ var autoSprint by toggle("auto-sprint") { false }
+ val autoSprintKeyBinding by keyBindingWithDefaultUnbound("auto-sprint-keybinding")
+ val autoSprintHud by position("auto-sprint-hud", 80, 10) { Point(0.0, 1.0) }
+ val peekChat by keyBindingWithDefaultUnbound("peek-chat")
+ }
+
+ override val config: ManagedConfig
+ get() = TConfig
+
+ fun handleIsPressed(
+ keyBinding: KeyBinding,
+ cir: CallbackInfoReturnable<Boolean>
+ ) {
+ if (keyBinding === MinecraftClient.getInstance().options.sprintKey && TConfig.autoSprint && MC.player?.isSprinting != true)
+ cir.returnValue = true
+ }
+
+ @Subscribe
+ fun onRenderHud(it: HudRenderEvent) {
+ if (!TConfig.autoSprintKeyBinding.isBound) return
+ it.context.matrices.push()
+ TConfig.autoSprintHud.applyTransformations(it.context.matrices)
+ it.context.drawText(
+ MC.font, Text.translatable(
+ if (TConfig.autoSprint)
+ "firmament.fixes.auto-sprint.on"
+ else if (MC.player?.isSprinting == true)
+ "firmament.fixes.auto-sprint.sprinting"
+ else
+ "firmament.fixes.auto-sprint.not-sprinting"
+ ), 0, 0, -1, false
+ )
+ it.context.matrices.pop()
+ }
+
+ @Subscribe
+ fun onWorldKeyboard(it: WorldKeyboardEvent) {
+ if (it.matches(TConfig.autoSprintKeyBinding)) {
+ TConfig.autoSprint = !TConfig.autoSprint
+ }
+ }
+
+ fun shouldPeekChat(): Boolean {
+ return TConfig.peekChat.isPressed(atLeast = true)
+ }
+}
diff --git a/src/main/kotlin/features/inventory/CraftingOverlay.kt b/src/main/kotlin/features/inventory/CraftingOverlay.kt
new file mode 100644
index 0000000..031ef78
--- /dev/null
+++ b/src/main/kotlin/features/inventory/CraftingOverlay.kt
@@ -0,0 +1,66 @@
+
+
+package moe.nea.firmament.features.inventory
+
+import net.minecraft.client.gui.screen.ingame.GenericContainerScreen
+import net.minecraft.item.ItemStack
+import net.minecraft.util.Formatting
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.SlotRenderEvents
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.rei.FirmamentReiPlugin.Companion.asItemEntry
+import moe.nea.firmament.rei.SBItemEntryDefinition
+import moe.nea.firmament.rei.recipes.SBCraftingRecipe
+import moe.nea.firmament.util.MC
+
+object CraftingOverlay : FirmamentFeature {
+
+ private var screen: GenericContainerScreen? = null
+ private var recipe: SBCraftingRecipe? = null
+ private val craftingOverlayIndices = listOf(
+ 10, 11, 12,
+ 19, 20, 21,
+ 28, 29, 30,
+ )
+
+
+ fun setOverlay(screen: GenericContainerScreen, recipe: SBCraftingRecipe) {
+ this.screen = screen
+ this.recipe = recipe
+ }
+
+ override val identifier: String
+ get() = "crafting-overlay"
+
+ @Subscribe
+ fun onSlotRender(event: SlotRenderEvents.After) {
+ val slot = event.slot
+ val recipe = this.recipe ?: return
+ if (slot.inventory != screen?.screenHandler?.inventory) return
+ val recipeIndex = craftingOverlayIndices.indexOf(slot.index)
+ if (recipeIndex < 0) return
+ val expectedItem = recipe.neuRecipe.inputs[recipeIndex]
+ val actualStack = slot.stack ?: ItemStack.EMPTY!!
+ val actualEntry = SBItemEntryDefinition.getEntry(actualStack).value
+ if ((actualEntry.skyblockId.neuItem != expectedItem.itemId || actualEntry.getStackSize() < expectedItem.amount) && expectedItem.amount.toInt() != 0) {
+ event.context.fill(
+ event.slot.x,
+ event.slot.y,
+ event.slot.x + 16,
+ event.slot.y + 16,
+ 0x80FF0000.toInt()
+ )
+ }
+ if (!slot.hasStack()) {
+ val itemStack = SBItemEntryDefinition.getEntry(expectedItem).asItemEntry().value
+ event.context.drawItem(itemStack, event.slot.x, event.slot.y)
+ event.context.drawItemInSlot(
+ MC.font,
+ itemStack,
+ event.slot.x,
+ event.slot.y,
+ "${Formatting.RED}${expectedItem.amount.toInt()}"
+ )
+ }
+ }
+}
diff --git a/src/main/kotlin/features/inventory/ItemRarityCosmetics.kt b/src/main/kotlin/features/inventory/ItemRarityCosmetics.kt
new file mode 100644
index 0000000..566a813
--- /dev/null
+++ b/src/main/kotlin/features/inventory/ItemRarityCosmetics.kt
@@ -0,0 +1,85 @@
+
+
+package moe.nea.firmament.features.inventory
+
+import java.awt.Color
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.item.ItemStack
+import net.minecraft.util.Formatting
+import net.minecraft.util.Identifier
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.HotbarItemRenderEvent
+import moe.nea.firmament.events.SlotRenderEvents
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.item.loreAccordingToNbt
+import moe.nea.firmament.util.lastNotNullOfOrNull
+import moe.nea.firmament.util.memoize
+import moe.nea.firmament.util.memoizeIdentity
+import moe.nea.firmament.util.unformattedString
+
+object ItemRarityCosmetics : FirmamentFeature {
+ override val identifier: String
+ get() = "item-rarity-cosmetics"
+
+ object TConfig : ManagedConfig(identifier) {
+ val showItemRarityBackground by toggle("background") { false }
+ val showItemRarityInHotbar by toggle("background-hotbar") { false }
+ }
+
+ override val config: ManagedConfig
+ get() = TConfig
+
+ private val rarityToColor = mapOf(
+ "UNCOMMON" to Formatting.GREEN,
+ "COMMON" to Formatting.WHITE,
+ "RARE" to Formatting.DARK_BLUE,
+ "EPIC" to Formatting.DARK_PURPLE,
+ "LEGENDARY" to Formatting.GOLD,
+ "LEGENJERRY" to Formatting.GOLD,
+ "MYTHIC" to Formatting.LIGHT_PURPLE,
+ "DIVINE" to Formatting.BLUE,
+ "SPECIAL" to Formatting.DARK_RED,
+ "SUPREME" to Formatting.DARK_RED,
+ ).mapValues {
+ val c = Color(it.value.colorValue!!)
+ Triple(c.red / 255F, c.green / 255F, c.blue / 255F)
+ }
+
+ private fun getSkyblockRarity0(itemStack: ItemStack): Triple<Float, Float, Float>? {
+ return itemStack.loreAccordingToNbt.lastNotNullOfOrNull {
+ val entry = it.unformattedString
+ rarityToColor.entries.find { (k, v) -> k in entry }?.value
+ }
+ }
+
+ val getSkyblockRarity = ::getSkyblockRarity0.memoizeIdentity(100)
+
+
+ fun drawItemStackRarity(drawContext: DrawContext, x: Int, y: Int, item: ItemStack) {
+ val (r, g, b) = getSkyblockRarity(item) ?: return
+ drawContext.drawSprite(
+ x, y,
+ 0,
+ 16, 16,
+ MC.guiAtlasManager.getSprite(Identifier.of("firmament:item_rarity_background")),
+ r, g, b, 1F
+ )
+ }
+
+
+ @Subscribe
+ fun onRenderSlot(it: SlotRenderEvents.Before) {
+ if (!TConfig.showItemRarityBackground) return
+ val stack = it.slot.stack ?: return
+ drawItemStackRarity(it.context, it.slot.x, it.slot.y, stack)
+ }
+
+ @Subscribe
+ fun onRenderHotbarItem(it: HotbarItemRenderEvent) {
+ if (!TConfig.showItemRarityInHotbar) return
+ val stack = it.item
+ drawItemStackRarity(it.context, it.x, it.y, stack)
+ }
+}
diff --git a/src/main/kotlin/features/inventory/PriceData.kt b/src/main/kotlin/features/inventory/PriceData.kt
new file mode 100644
index 0000000..c61f8e8
--- /dev/null
+++ b/src/main/kotlin/features/inventory/PriceData.kt
@@ -0,0 +1,51 @@
+
+
+package moe.nea.firmament.features.inventory
+
+import net.minecraft.text.Text
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.ItemTooltipEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.repo.HypixelStaticData
+import moe.nea.firmament.util.FirmFormatters
+import moe.nea.firmament.util.skyBlockId
+
+object PriceData : FirmamentFeature {
+ override val identifier: String
+ get() = "price-data"
+
+ object TConfig : ManagedConfig(identifier) {
+ val tooltipEnabled by toggle("enable-always") { true }
+ val enableKeybinding by keyBindingWithDefaultUnbound("enable-keybind")
+ }
+
+ override val config get() = TConfig
+
+ @Subscribe
+ fun onItemTooltip(it: ItemTooltipEvent) {
+ if (!TConfig.tooltipEnabled && !TConfig.enableKeybinding.isPressed()) {
+ return
+ }
+ val sbId = it.stack.skyBlockId
+ val bazaarData = HypixelStaticData.bazaarData[sbId]
+ val lowestBin = HypixelStaticData.lowestBin[sbId]
+ if (bazaarData != null) {
+ it.lines.add(Text.literal(""))
+ it.lines.add(
+ Text.stringifiedTranslatable("firmament.tooltip.bazaar.sell-order",
+ FirmFormatters.formatCommas(bazaarData.quickStatus.sellPrice, 1))
+ )
+ it.lines.add(
+ Text.stringifiedTranslatable("firmament.tooltip.bazaar.buy-order",
+ FirmFormatters.formatCommas(bazaarData.quickStatus.buyPrice, 1))
+ )
+ } else if (lowestBin != null) {
+ it.lines.add(Text.literal(""))
+ it.lines.add(
+ Text.stringifiedTranslatable("firmament.tooltip.ah.lowestbin",
+ FirmFormatters.formatCommas(lowestBin, 1))
+ )
+ }
+ }
+}
diff --git a/src/main/kotlin/features/inventory/SaveCursorPosition.kt b/src/main/kotlin/features/inventory/SaveCursorPosition.kt
new file mode 100644
index 0000000..1c55753
--- /dev/null
+++ b/src/main/kotlin/features/inventory/SaveCursorPosition.kt
@@ -0,0 +1,66 @@
+
+
+package moe.nea.firmament.features.inventory
+
+import kotlin.math.absoluteValue
+import kotlin.time.Duration.Companion.milliseconds
+import net.minecraft.client.util.InputUtil
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.TimeMark
+import moe.nea.firmament.util.assertNotNullOr
+
+object SaveCursorPosition : FirmamentFeature {
+ override val identifier: String
+ get() = "save-cursor-position"
+
+ object TConfig : ManagedConfig(identifier) {
+ val enable by toggle("enable") { true }
+ val tolerance by duration("tolerance", 10.milliseconds, 5000.milliseconds) { 500.milliseconds }
+ }
+
+ override val config: TConfig
+ get() = TConfig
+
+ var savedPositionedP1: Pair<Double, Double>? = null
+ var savedPosition: SavedPosition? = null
+
+ data class SavedPosition(
+ val middle: Pair<Double, Double>,
+ val cursor: Pair<Double, Double>,
+ val savedAt: TimeMark = TimeMark.now()
+ )
+
+ @JvmStatic
+ fun saveCursorOriginal(positionedX: Double, positionedY: Double) {
+ savedPositionedP1 = Pair(positionedX, positionedY)
+ }
+
+ @JvmStatic
+ fun loadCursor(middleX: Double, middleY: Double): Pair<Double, Double>? {
+ if (!TConfig.enable) return null
+ val lastPosition = savedPosition?.takeIf { it.savedAt.passedTime() < TConfig.tolerance }
+ savedPosition = null
+ if (lastPosition != null &&
+ (lastPosition.middle.first - middleX).absoluteValue < 1 &&
+ (lastPosition.middle.second - middleY).absoluteValue < 1
+ ) {
+ InputUtil.setCursorParameters(
+ MC.window.handle,
+ InputUtil.GLFW_CURSOR_NORMAL,
+ lastPosition.cursor.first,
+ lastPosition.cursor.second
+ )
+ return lastPosition.cursor
+ }
+ return null
+ }
+
+ @JvmStatic
+ fun saveCursorMiddle(middleX: Double, middleY: Double) {
+ if (!TConfig.enable) return
+ val cursorPos = assertNotNullOr(savedPositionedP1) { return }
+ savedPosition = SavedPosition(Pair(middleX, middleY), cursorPos)
+ }
+}
diff --git a/src/main/kotlin/features/inventory/SlotLocking.kt b/src/main/kotlin/features/inventory/SlotLocking.kt
new file mode 100644
index 0000000..a50d8fb
--- /dev/null
+++ b/src/main/kotlin/features/inventory/SlotLocking.kt
@@ -0,0 +1,203 @@
+
+
+@file:UseSerializers(DashlessUUIDSerializer::class)
+
+package moe.nea.firmament.features.inventory
+
+import com.mojang.blaze3d.systems.RenderSystem
+import java.util.UUID
+import org.lwjgl.glfw.GLFW
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.UseSerializers
+import kotlinx.serialization.serializer
+import net.minecraft.client.gui.screen.ingame.HandledScreen
+import net.minecraft.entity.player.PlayerInventory
+import net.minecraft.screen.GenericContainerScreenHandler
+import net.minecraft.screen.slot.SlotActionType
+import net.minecraft.util.Identifier
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.HandledScreenKeyPressedEvent
+import moe.nea.firmament.events.IsSlotProtectedEvent
+import moe.nea.firmament.events.SlotRenderEvents
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.keybindings.SavedKeyBinding
+import moe.nea.firmament.mixins.accessor.AccessorHandledScreen
+import moe.nea.firmament.util.CommonSoundEffects
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.SBData
+import moe.nea.firmament.util.SkyBlockIsland
+import moe.nea.firmament.util.data.ProfileSpecificDataHolder
+import moe.nea.firmament.util.item.displayNameAccordingToNbt
+import moe.nea.firmament.util.item.loreAccordingToNbt
+import moe.nea.firmament.util.json.DashlessUUIDSerializer
+import moe.nea.firmament.util.skyblockUUID
+import moe.nea.firmament.util.unformattedString
+
+object SlotLocking : FirmamentFeature {
+ override val identifier: String
+ get() = "slot-locking"
+
+ @Serializable
+ data class Data(
+ val lockedSlots: MutableSet<Int> = mutableSetOf(),
+ val lockedSlotsRift: MutableSet<Int> = mutableSetOf(),
+
+ val lockedUUIDs: MutableSet<UUID> = mutableSetOf(),
+ )
+
+ object TConfig : ManagedConfig(identifier) {
+ val lockSlot by keyBinding("lock") { GLFW.GLFW_KEY_L }
+ val lockUUID by keyBindingWithOutDefaultModifiers("lock-uuid") {
+ SavedKeyBinding(GLFW.GLFW_KEY_L, shift = true)
+ }
+ }
+
+ override val config: TConfig
+ get() = TConfig
+
+ object DConfig : ProfileSpecificDataHolder<Data>(serializer(), "locked-slots", ::Data)
+
+ val lockedUUIDs get() = DConfig.data?.lockedUUIDs
+
+ val lockedSlots
+ get() = when (SBData.skyblockLocation) {
+ SkyBlockIsland.RIFT -> DConfig.data?.lockedSlotsRift
+ null -> null
+ else -> DConfig.data?.lockedSlots
+ }
+
+ fun isSalvageScreen(screen: HandledScreen<*>?): Boolean {
+ if (screen == null) return false
+ return screen.title.unformattedString.contains("Salvage Item")
+ }
+
+ fun isTradeScreen(screen: HandledScreen<*>?): Boolean {
+ if (screen == null) return false
+ val handler = screen.screenHandler as? GenericContainerScreenHandler ?: return false
+ if (handler.inventory.size() < 9) return false
+ val middlePane = handler.inventory.getStack(handler.inventory.size() - 5)
+ if (middlePane == null) return false
+ return middlePane.displayNameAccordingToNbt?.unformattedString == "⇦ Your stuff"
+ }
+
+ fun isNpcShop(screen: HandledScreen<*>?): Boolean {
+ if (screen == null) return false
+ val handler = screen.screenHandler as? GenericContainerScreenHandler ?: return false
+ if (handler.inventory.size() < 9) return false
+ val sellItem = handler.inventory.getStack(handler.inventory.size() - 5)
+ if (sellItem == null) return false
+ if (sellItem.displayNameAccordingToNbt?.unformattedString == "Sell Item") return true
+ val lore = sellItem.loreAccordingToNbt
+ return (lore.lastOrNull() ?: return false).unformattedString == "Click to buyback!"
+ }
+
+ @Subscribe
+ fun onSalvageProtect(event: IsSlotProtectedEvent) {
+ if (event.slot == null) return
+ if (!event.slot.hasStack()) return
+ if (event.slot.stack.displayNameAccordingToNbt?.unformattedString != "Salvage Items") return
+ val inv = event.slot.inventory
+ var anyBlocked = false
+ for (i in 0 until event.slot.index) {
+ val stack = inv.getStack(i)
+ if (IsSlotProtectedEvent.shouldBlockInteraction(null, SlotActionType.THROW, stack))
+ anyBlocked = true
+ }
+ if (anyBlocked) {
+ event.protectSilent()
+ }
+ }
+
+ @Subscribe
+ fun onProtectUuidItems(event: IsSlotProtectedEvent) {
+ val doesNotDeleteItem = event.actionType == SlotActionType.SWAP
+ || event.actionType == SlotActionType.PICKUP
+ || event.actionType == SlotActionType.QUICK_MOVE
+ || event.actionType == SlotActionType.QUICK_CRAFT
+ || event.actionType == SlotActionType.CLONE
+ || event.actionType == SlotActionType.PICKUP_ALL
+ val isSellOrTradeScreen =
+ isNpcShop(MC.handledScreen) || isTradeScreen(MC.handledScreen) || isSalvageScreen(MC.handledScreen)
+ if ((!isSellOrTradeScreen || event.slot?.inventory !is PlayerInventory)
+ && doesNotDeleteItem
+ ) return
+ val stack = event.itemStack ?: return
+ val uuid = stack.skyblockUUID ?: return
+ if (uuid in (lockedUUIDs ?: return)) {
+ event.protect()
+ }
+ }
+
+ @Subscribe
+ fun onProtectSlot(it: IsSlotProtectedEvent) {
+ if (it.slot != null && it.slot.inventory is PlayerInventory && it.slot.index in (lockedSlots ?: setOf())) {
+ it.protect()
+ }
+ }
+
+ @Subscribe
+ fun onLockUUID(it: HandledScreenKeyPressedEvent) {
+ if (!it.matches(TConfig.lockUUID)) return
+ val inventory = MC.handledScreen ?: return
+ inventory as AccessorHandledScreen
+
+ val slot = inventory.focusedSlot_Firmament ?: return
+ val stack = slot.stack ?: return
+ val uuid = stack.skyblockUUID ?: return
+ val lockedUUIDs = lockedUUIDs ?: return
+ if (uuid in lockedUUIDs) {
+ lockedUUIDs.remove(uuid)
+ } else {
+ lockedUUIDs.add(uuid)
+ }
+ DConfig.markDirty()
+ CommonSoundEffects.playSuccess()
+ it.cancel()
+ }
+
+ @Subscribe
+ fun onLockSlot(it: HandledScreenKeyPressedEvent) {
+ if (!it.matches(TConfig.lockSlot)) return
+ val inventory = MC.handledScreen ?: return
+ inventory as AccessorHandledScreen
+
+ val slot = inventory.focusedSlot_Firmament ?: return
+ val lockedSlots = lockedSlots ?: return
+ if (slot.inventory is PlayerInventory) {
+ if (slot.index in lockedSlots) {
+ lockedSlots.remove(slot.index)
+ } else {
+ lockedSlots.add(slot.index)
+ }
+ DConfig.markDirty()
+ CommonSoundEffects.playSuccess()
+ }
+ }
+
+ @Subscribe
+ fun onRenderSlotOverlay(it: SlotRenderEvents.After) {
+ val isSlotLocked = it.slot.inventory is PlayerInventory && it.slot.index in (lockedSlots ?: setOf())
+ val isUUIDLocked = (it.slot.stack?.skyblockUUID) in (lockedUUIDs ?: setOf())
+ if (isSlotLocked || isUUIDLocked) {
+ RenderSystem.disableDepthTest()
+ it.context.drawSprite(
+ it.slot.x, it.slot.y, 0,
+ 16, 16,
+ MC.guiAtlasManager.getSprite(
+ when {
+ isSlotLocked ->
+ (Identifier.of("firmament:slot_locked"))
+
+ isUUIDLocked ->
+ (Identifier.of("firmament:uuid_locked"))
+
+ else ->
+ error("unreachable")
+ }
+ )
+ )
+ RenderSystem.enableDepthTest()
+ }
+ }
+}
diff --git a/src/main/kotlin/features/inventory/buttons/InventoryButton.kt b/src/main/kotlin/features/inventory/buttons/InventoryButton.kt
new file mode 100644
index 0000000..539edf2
--- /dev/null
+++ b/src/main/kotlin/features/inventory/buttons/InventoryButton.kt
@@ -0,0 +1,85 @@
+
+
+package moe.nea.firmament.features.inventory.buttons
+
+import com.mojang.brigadier.StringReader
+import me.shedaniel.math.Dimension
+import me.shedaniel.math.Point
+import me.shedaniel.math.Rectangle
+import kotlinx.serialization.Serializable
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.command.CommandRegistryAccess
+import net.minecraft.command.argument.ItemStackArgumentType
+import net.minecraft.item.ItemStack
+import net.minecraft.resource.featuretoggle.FeatureFlags
+import net.minecraft.util.Identifier
+import moe.nea.firmament.repo.ItemCache.asItemStack
+import moe.nea.firmament.repo.RepoManager
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.SkyblockId
+import moe.nea.firmament.util.memoize
+
+@Serializable
+data class InventoryButton(
+ var x: Int,
+ var y: Int,
+ var anchorRight: Boolean,
+ var anchorBottom: Boolean,
+ var icon: String? = "",
+ var command: String? = "",
+) {
+ companion object {
+ val itemStackParser by lazy {
+ ItemStackArgumentType.itemStack(CommandRegistryAccess.of(MC.defaultRegistries,
+ FeatureFlags.VANILLA_FEATURES))
+ }
+ val dimensions = Dimension(18, 18)
+ val getItemForName = ::getItemForName0.memoize(1024)
+ fun getItemForName0(icon: String): ItemStack {
+ val repoItem = RepoManager.getNEUItem(SkyblockId(icon))
+ var itemStack = repoItem.asItemStack(idHint = SkyblockId(icon))
+ if (repoItem == null) {
+ val giveSyntaxItem = if (icon.startsWith("/give") || icon.startsWith("give"))
+ icon.split(" ", limit = 3).getOrNull(2) ?: icon
+ else icon
+ val componentItem =
+ runCatching {
+ itemStackParser.parse(StringReader(giveSyntaxItem)).createStack(1, false)
+ }.getOrNull()
+ if (componentItem != null)
+ itemStack = componentItem
+ }
+ return itemStack
+ }
+ }
+
+ fun render(context: DrawContext) {
+ context.drawSprite(
+ 0,
+ 0,
+ 0,
+ dimensions.width,
+ dimensions.height,
+ MC.guiAtlasManager.getSprite(Identifier.of("firmament:inventory_button_background"))
+ )
+ context.drawItem(getItem(), 1, 1)
+ }
+
+ fun isValid() = !icon.isNullOrBlank() && !command.isNullOrBlank()
+
+ fun getPosition(guiRect: Rectangle): Point {
+ return Point(
+ (if (anchorRight) guiRect.maxX else guiRect.minX) + x,
+ (if (anchorBottom) guiRect.maxY else guiRect.minY) + y,
+ )
+ }
+
+ fun getBounds(guiRect: Rectangle): Rectangle {
+ return Rectangle(getPosition(guiRect), dimensions)
+ }
+
+ fun getItem(): ItemStack {
+ return getItemForName(icon ?: "")
+ }
+
+}
diff --git a/src/main/kotlin/features/inventory/buttons/InventoryButtonEditor.kt b/src/main/kotlin/features/inventory/buttons/InventoryButtonEditor.kt
new file mode 100644
index 0000000..c57563e
--- /dev/null
+++ b/src/main/kotlin/features/inventory/buttons/InventoryButtonEditor.kt
@@ -0,0 +1,184 @@
+
+
+package moe.nea.firmament.features.inventory.buttons
+
+import io.github.notenoughupdates.moulconfig.common.IItemStack
+import io.github.notenoughupdates.moulconfig.platform.ModernItemStack
+import io.github.notenoughupdates.moulconfig.xml.Bind
+import me.shedaniel.math.Point
+import me.shedaniel.math.Rectangle
+import org.lwjgl.glfw.GLFW
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.widget.ButtonWidget
+import net.minecraft.client.util.InputUtil
+import net.minecraft.text.Text
+import net.minecraft.util.math.MathHelper
+import net.minecraft.util.math.Vec2f
+import moe.nea.firmament.util.ClipboardUtils
+import moe.nea.firmament.util.FragmentGuiScreen
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.MoulConfigUtils
+
+class InventoryButtonEditor(
+ val lastGuiRect: Rectangle,
+) : FragmentGuiScreen() {
+ inner class Editor(val originalButton: InventoryButton) {
+ @field:Bind
+ var command: String = originalButton.command ?: ""
+
+ @field:Bind
+ var icon: String = originalButton.icon ?: ""
+
+ @Bind
+ fun getItemIcon(): IItemStack {
+ save()
+ return ModernItemStack.of(InventoryButton.getItemForName(icon))
+ }
+
+ @Bind
+ fun delete() {
+ buttons.removeIf { it === originalButton }
+ popup = null
+ }
+
+ fun save() {
+ originalButton.icon = icon
+ originalButton.command = command
+ }
+ }
+
+ var buttons: MutableList<InventoryButton> =
+ InventoryButtons.DConfig.data.buttons.map { it.copy() }.toMutableList()
+
+ override fun close() {
+ InventoryButtons.DConfig.data.buttons = buttons
+ InventoryButtons.DConfig.markDirty()
+ super.close()
+ }
+
+ override fun init() {
+ super.init()
+ addDrawableChild(
+ ButtonWidget.builder(Text.translatable("firmament.inventory-buttons.load-preset")) {
+ val t = ClipboardUtils.getTextContents()
+ val newButtons = InventoryButtonTemplates.loadTemplate(t)
+ if (newButtons != null)
+ buttons = newButtons.toMutableList()
+ }
+ .position(lastGuiRect.minX + 10, lastGuiRect.minY + 35)
+ .width(lastGuiRect.width - 20)
+ .build()
+ )
+ addDrawableChild(
+ ButtonWidget.builder(Text.translatable("firmament.inventory-buttons.save-preset")) {
+ ClipboardUtils.setTextContent(InventoryButtonTemplates.saveTemplate(buttons))
+ }
+ .position(lastGuiRect.minX + 10, lastGuiRect.minY + 60)
+ .width(lastGuiRect.width - 20)
+ .build()
+ )
+ }
+
+ override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+ super.render(context, mouseX, mouseY, delta)
+ context.matrices.push()
+ context.matrices.translate(0f, 0f, -10f)
+ context.fill(lastGuiRect.minX, lastGuiRect.minY, lastGuiRect.maxX, lastGuiRect.maxY, -1)
+ context.setShaderColor(1f, 1f, 1f, 1f)
+ context.matrices.pop()
+ for (button in buttons) {
+ val buttonPosition = button.getBounds(lastGuiRect)
+ context.matrices.push()
+ context.matrices.translate(buttonPosition.minX.toFloat(), buttonPosition.minY.toFloat(), 0F)
+ button.render(context)
+ context.matrices.pop()
+ }
+ }
+
+ override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean {
+ if (super.keyPressed(keyCode, scanCode, modifiers)) return true
+ if (keyCode == GLFW.GLFW_KEY_ESCAPE) {
+ close()
+ return true
+ }
+ return false
+ }
+
+ override fun mouseReleased(mouseX: Double, mouseY: Double, button: Int): Boolean {
+ if (super.mouseReleased(mouseX, mouseY, button)) return true
+ val clickedButton = buttons.firstOrNull { it.getBounds(lastGuiRect).contains(Point(mouseX, mouseY)) }
+ if (clickedButton != null && !justPerformedAClickAction) {
+ createPopup(MoulConfigUtils.loadGui("button_editor_fragment", Editor(clickedButton)), Point(mouseX, mouseY))
+ return true
+ }
+ justPerformedAClickAction = false
+ lastDraggedButton = null
+ return false
+ }
+
+ override fun mouseDragged(mouseX: Double, mouseY: Double, button: Int, deltaX: Double, deltaY: Double): Boolean {
+ if (super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)) return true
+
+ if (initialDragMousePosition.distanceSquared(Vec2f(mouseX.toFloat(), mouseY.toFloat())) >= 4 * 4) {
+ initialDragMousePosition = Vec2f(-10F, -10F)
+ lastDraggedButton?.let { dragging ->
+ justPerformedAClickAction = true
+ val (anchorRight, anchorBottom, offsetX, offsetY) = getCoordsForMouse(mouseX.toInt(), mouseY.toInt())
+ ?: return true
+ dragging.x = offsetX
+ dragging.y = offsetY
+ dragging.anchorRight = anchorRight
+ dragging.anchorBottom = anchorBottom
+ }
+ }
+ return false
+ }
+
+ var lastDraggedButton: InventoryButton? = null
+ var justPerformedAClickAction = false
+ var initialDragMousePosition = Vec2f(-10F, -10F)
+
+ data class AnchoredCoords(
+ val anchorRight: Boolean,
+ val anchorBottom: Boolean,
+ val offsetX: Int,
+ val offsetY: Int,
+ )
+
+ fun getCoordsForMouse(mx: Int, my: Int): AnchoredCoords? {
+ if (lastGuiRect.contains(mx, my) || lastGuiRect.contains(
+ Point(
+ mx + InventoryButton.dimensions.width,
+ my + InventoryButton.dimensions.height,
+ )
+ )
+ ) return null
+
+ val anchorRight = mx > lastGuiRect.maxX
+ val anchorBottom = my > lastGuiRect.maxY
+ var offsetX = mx - if (anchorRight) lastGuiRect.maxX else lastGuiRect.minX
+ var offsetY = my - if (anchorBottom) lastGuiRect.maxY else lastGuiRect.minY
+ if (InputUtil.isKeyPressed(MC.window.handle, InputUtil.GLFW_KEY_LEFT_SHIFT)) {
+ offsetX = MathHelper.floor(offsetX / 20F) * 20
+ offsetY = MathHelper.floor(offsetY / 20F) * 20
+ }
+ return AnchoredCoords(anchorRight, anchorBottom, offsetX, offsetY)
+ }
+
+ override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean {
+ if (super.mouseClicked(mouseX, mouseY, button)) return true
+ val clickedButton = buttons.firstOrNull { it.getBounds(lastGuiRect).contains(Point(mouseX, mouseY)) }
+ if (clickedButton != null) {
+ lastDraggedButton = clickedButton
+ initialDragMousePosition = Vec2f(mouseX.toFloat(), mouseY.toFloat())
+ return true
+ }
+ val mx = mouseX.toInt()
+ val my = mouseY.toInt()
+ val (anchorRight, anchorBottom, offsetX, offsetY) = getCoordsForMouse(mx, my) ?: return true
+ buttons.add(InventoryButton(offsetX, offsetY, anchorRight, anchorBottom, null, null))
+ justPerformedAClickAction = true
+ return true
+ }
+
+}
diff --git a/src/main/kotlin/features/inventory/buttons/InventoryButtonTemplates.kt b/src/main/kotlin/features/inventory/buttons/InventoryButtonTemplates.kt
new file mode 100644
index 0000000..99b544b
--- /dev/null
+++ b/src/main/kotlin/features/inventory/buttons/InventoryButtonTemplates.kt
@@ -0,0 +1,35 @@
+
+
+package moe.nea.firmament.features.inventory.buttons
+
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import net.minecraft.text.Text
+import moe.nea.firmament.Firmament
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.TemplateUtil
+
+object InventoryButtonTemplates {
+
+ val legacyPrefix = "NEUBUTTONS/"
+ val modernPrefix = "MAYBEONEDAYIWILLHAVEMYOWNFORMAT"
+
+ fun loadTemplate(t: String): List<InventoryButton>? {
+ val buttons = TemplateUtil.maybeDecodeTemplate<List<String>>(legacyPrefix, t) ?: return null
+ return buttons.mapNotNull {
+ try {
+ Firmament.json.decodeFromString<InventoryButton>(it).also {
+ if (it.icon?.startsWith("extra:") == true || it.command?.any { it.isLowerCase() } == true) {
+ MC.sendChat(Text.translatable("firmament.inventory-buttons.import-failed"))
+ }
+ }
+ } catch (e: Exception) {
+ null
+ }
+ }
+ }
+
+ fun saveTemplate(buttons: List<InventoryButton>): String {
+ return TemplateUtil.encodeTemplate(legacyPrefix, buttons.map { Firmament.json.encodeToString(it) })
+ }
+}
diff --git a/src/main/kotlin/features/inventory/buttons/InventoryButtons.kt b/src/main/kotlin/features/inventory/buttons/InventoryButtons.kt
new file mode 100644
index 0000000..fa90d21
--- /dev/null
+++ b/src/main/kotlin/features/inventory/buttons/InventoryButtons.kt
@@ -0,0 +1,88 @@
+
+
+package moe.nea.firmament.features.inventory.buttons
+
+import me.shedaniel.math.Rectangle
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.serializer
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.HandledScreenClickEvent
+import moe.nea.firmament.events.HandledScreenForegroundEvent
+import moe.nea.firmament.events.HandledScreenPushREIEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.ScreenUtil
+import moe.nea.firmament.util.data.DataHolder
+import moe.nea.firmament.util.getRectangle
+
+object InventoryButtons : FirmamentFeature {
+ override val identifier: String
+ get() = "inventory-buttons"
+
+ object TConfig : ManagedConfig(identifier) {
+ val _openEditor by button("open-editor") {
+ openEditor()
+ }
+ }
+
+ object DConfig : DataHolder<Data>(serializer(), identifier, ::Data)
+
+ @Serializable
+ data class Data(
+ var buttons: MutableList<InventoryButton> = mutableListOf()
+ )
+
+
+ override val config: ManagedConfig
+ get() = TConfig
+
+ fun getValidButtons() = DConfig.data.buttons.asSequence().filter { it.isValid() }
+
+ @Subscribe
+ fun onRectangles(it: HandledScreenPushREIEvent) {
+ val bounds = it.screen.getRectangle()
+ for (button in getValidButtons()) {
+ val buttonBounds = button.getBounds(bounds)
+ it.block(buttonBounds)
+ }
+ }
+
+ @Subscribe
+ fun onClickScreen(it: HandledScreenClickEvent) {
+ val bounds = it.screen.getRectangle()
+ for (button in getValidButtons()) {
+ val buttonBounds = button.getBounds(bounds)
+ if (buttonBounds.contains(it.mouseX, it.mouseY)) {
+ MC.sendCommand(button.command!! /* non null invariant covered by getValidButtons */)
+ break
+ }
+ }
+ }
+
+ @Subscribe
+ fun onRenderForeground(it: HandledScreenForegroundEvent) {
+ val bounds = it.screen.getRectangle()
+ for (button in getValidButtons()) {
+ val buttonBounds = button.getBounds(bounds)
+ it.context.matrices.push()
+ it.context.matrices.translate(buttonBounds.minX.toFloat(), buttonBounds.minY.toFloat(), 0F)
+ button.render(it.context)
+ it.context.matrices.pop()
+ }
+ lastRectangle = bounds
+ }
+
+ var lastRectangle: Rectangle? = null
+ fun openEditor() {
+ ScreenUtil.setScreenLater(
+ InventoryButtonEditor(
+ lastRectangle ?: Rectangle(
+ MC.window.scaledWidth / 2 - 100,
+ MC.window.scaledHeight / 2 - 100,
+ 200, 200,
+ )
+ )
+ )
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageBackingHandle.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageBackingHandle.kt
new file mode 100644
index 0000000..1015578
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StorageBackingHandle.kt
@@ -0,0 +1,53 @@
+
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import net.minecraft.client.gui.screen.Screen
+import net.minecraft.client.gui.screen.ingame.GenericContainerScreen
+import net.minecraft.screen.GenericContainerScreenHandler
+import moe.nea.firmament.util.ifMatches
+import moe.nea.firmament.util.unformattedString
+
+/**
+ * A handle representing the state of the "server side" screens.
+ */
+sealed interface StorageBackingHandle {
+
+ sealed interface HasBackingScreen {
+ val handler: GenericContainerScreenHandler
+ }
+
+ /**
+ * The main storage overview is open. Clicking on a slot will open that page. This page is accessible via `/storage`
+ */
+ data class Overview(override val handler: GenericContainerScreenHandler) : StorageBackingHandle, HasBackingScreen
+
+ /**
+ * An individual storage page is open. This may be a backpack or an enderchest page. This page is accessible via
+ * the [Overview] or via `/ec <index + 1>` for enderchest pages.
+ */
+ data class Page(override val handler: GenericContainerScreenHandler, val storagePageSlot: StoragePageSlot) :
+ StorageBackingHandle, HasBackingScreen
+
+ companion object {
+ private val enderChestName = "^Ender Chest \\(([1-9])/[1-9]\\)$".toRegex()
+ private val backPackName = "^.+Backpack \\(Slot #([0-9]+)\\)$".toRegex()
+
+ /**
+ * Parse a screen into a [StorageBackingHandle]. If this returns null it means that the screen is not
+ * representable as a [StorageBackingHandle], meaning another screen is open, for example the enderchest icon
+ * selection screen.
+ */
+ fun fromScreen(screen: Screen?): StorageBackingHandle? {
+ if (screen == null) return null
+ if (screen !is GenericContainerScreen) return null
+ val title = screen.title.unformattedString
+ if (title == "Storage") return Overview(screen.screenHandler)
+ return title.ifMatches(enderChestName) {
+ Page(screen.screenHandler, StoragePageSlot.ofEnderChestPage(it.groupValues[1].toInt()))
+ } ?: title.ifMatches(backPackName) {
+ Page(screen.screenHandler, StoragePageSlot.ofBackPackPage(it.groupValues[1].toInt()))
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageData.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageData.kt
new file mode 100644
index 0000000..7555c56
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StorageData.kt
@@ -0,0 +1,21 @@
+
+
+@file:UseSerializers(SortedMapSerializer::class)
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import java.util.SortedMap
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.UseSerializers
+import moe.nea.firmament.util.SortedMapSerializer
+
+@Serializable
+data class StorageData(
+ val storageInventories: SortedMap<StoragePageSlot, StorageInventory> = sortedMapOf()
+) {
+ @Serializable
+ data class StorageInventory(
+ var title: String,
+ val slot: StoragePageSlot,
+ var inventory: VirtualInventory?,
+ )
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageOverlay.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlay.kt
new file mode 100644
index 0000000..b615c73
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlay.kt
@@ -0,0 +1,154 @@
+
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import java.util.SortedMap
+import kotlinx.serialization.serializer
+import net.minecraft.client.gui.screen.ingame.GenericContainerScreen
+import net.minecraft.client.gui.screen.ingame.HandledScreen
+import net.minecraft.entity.player.PlayerInventory
+import net.minecraft.item.Items
+import net.minecraft.network.packet.c2s.play.CloseHandledScreenC2SPacket
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.ScreenChangeEvent
+import moe.nea.firmament.events.SlotClickEvent
+import moe.nea.firmament.events.TickEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.customgui.customGui
+import moe.nea.firmament.util.data.ProfileSpecificDataHolder
+
+object StorageOverlay : FirmamentFeature {
+
+
+ object Data : ProfileSpecificDataHolder<StorageData>(serializer(), "storage-data", ::StorageData)
+
+ override val identifier: String
+ get() = "storage-overlay"
+
+ object TConfig : ManagedConfig(identifier) {
+ val alwaysReplace by toggle("always-replace") { true }
+ val columns by integer("rows", 1, 10) { 3 }
+ val scrollSpeed by integer("scroll-speed", 1, 50) { 10 }
+ val inverseScroll by toggle("inverse-scroll") { false }
+ val padding by integer("padding", 1, 20) { 5 }
+ val margin by integer("margin", 1, 60) { 20 }
+ }
+
+ fun adjustScrollSpeed(amount: Double): Double {
+ return amount * TConfig.scrollSpeed * (if (TConfig.inverseScroll) 1 else -1)
+ }
+
+ override val config: TConfig
+ get() = TConfig
+
+ var lastStorageOverlay: StorageOverviewScreen? = null
+ var skipNextStorageOverlayBackflip = false
+ var currentHandler: StorageBackingHandle? = null
+
+ @Subscribe
+ fun onTick(event: TickEvent) {
+ rememberContent(currentHandler ?: return)
+ }
+
+ @Subscribe
+ fun onClick(event: SlotClickEvent) {
+ if (lastStorageOverlay != null && event.slot.inventory !is PlayerInventory && event.slot.index < 9
+ && event.stack.item != Items.BLACK_STAINED_GLASS_PANE
+ ) {
+ skipNextStorageOverlayBackflip = true
+ }
+ }
+
+ @Subscribe
+ fun onScreenChange(it: ScreenChangeEvent) {
+ if (it.old == null && it.new == null) return
+ val storageOverlayScreen = it.old as? StorageOverlayScreen
+ ?: ((it.old as? HandledScreen<*>)?.customGui as? StorageOverlayCustom)?.overview
+ var storageOverviewScreen = it.old as? StorageOverviewScreen
+ val screen = it.new as? GenericContainerScreen
+ val oldHandler = currentHandler
+ currentHandler = StorageBackingHandle.fromScreen(screen)
+ rememberContent(currentHandler)
+ if (storageOverviewScreen != null && oldHandler is StorageBackingHandle.HasBackingScreen) {
+ val player = MC.player
+ assert(player != null)
+ player?.networkHandler?.sendPacket(CloseHandledScreenC2SPacket(oldHandler.handler.syncId))
+ if (player?.currentScreenHandler === oldHandler.handler) {
+ player.currentScreenHandler = player.playerScreenHandler
+ }
+ }
+ storageOverviewScreen = storageOverviewScreen ?: lastStorageOverlay
+ if (it.new == null && storageOverlayScreen != null && !storageOverlayScreen.isExiting) {
+ it.overrideScreen = storageOverlayScreen
+ return
+ }
+ if (storageOverviewScreen != null
+ && !storageOverviewScreen.isClosing
+ && (currentHandler is StorageBackingHandle.Overview || currentHandler == null)
+ ) {
+ if (skipNextStorageOverlayBackflip) {
+ skipNextStorageOverlayBackflip = false
+ } else {
+ it.overrideScreen = storageOverviewScreen
+ lastStorageOverlay = null
+ }
+ return
+ }
+ screen ?: return
+ screen.customGui = StorageOverlayCustom(
+ currentHandler ?: return,
+ screen,
+ storageOverlayScreen ?: (if (TConfig.alwaysReplace) StorageOverlayScreen() else return))
+ }
+
+ fun rememberContent(handler: StorageBackingHandle?) {
+ handler ?: return
+ // TODO: Make all of these functions work on deltas / updates instead of the entire contents
+ val data = Data.data?.storageInventories ?: return
+ when (handler) {
+ is StorageBackingHandle.Overview -> rememberStorageOverview(handler, data)
+ is StorageBackingHandle.Page -> rememberPage(handler, data)
+ }
+ Data.markDirty()
+ }
+
+ private fun rememberStorageOverview(
+ handler: StorageBackingHandle.Overview,
+ data: SortedMap<StoragePageSlot, StorageData.StorageInventory>
+ ) {
+ for ((index, stack) in handler.handler.stacks.withIndex()) {
+ // Ignore unloaded item stacks
+ if (stack.isEmpty) continue
+ val slot = StoragePageSlot.fromOverviewSlotIndex(index) ?: continue
+ val isEmpty = stack.item in StorageOverviewScreen.emptyStorageSlotItems
+ if (slot in data) {
+ if (isEmpty)
+ data.remove(slot)
+ continue
+ }
+ if (!isEmpty) {
+ data[slot] = StorageData.StorageInventory(slot.defaultName(), slot, null)
+ }
+ }
+ }
+
+ private fun rememberPage(
+ handler: StorageBackingHandle.Page,
+ data: SortedMap<StoragePageSlot, StorageData.StorageInventory>
+ ) {
+ // TODO: FIXME: FIXME NOW: Definitely don't copy all of this every tick into persistence
+ val newStacks =
+ VirtualInventory(handler.handler.stacks.take(handler.handler.rows * 9).drop(9).map { it.copy() })
+ data.compute(handler.storagePageSlot) { slot, existingInventory ->
+ (existingInventory ?: StorageData.StorageInventory(
+ slot.defaultName(),
+ slot,
+ null
+ )).also {
+ it.inventory = newStacks
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayCustom.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayCustom.kt
new file mode 100644
index 0000000..d0d9114
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayCustom.kt
@@ -0,0 +1,98 @@
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import me.shedaniel.math.Point
+import me.shedaniel.math.Rectangle
+import net.minecraft.client.MinecraftClient
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.screen.ingame.GenericContainerScreen
+import net.minecraft.entity.player.PlayerInventory
+import net.minecraft.screen.slot.Slot
+import moe.nea.firmament.mixins.accessor.AccessorHandledScreen
+import moe.nea.firmament.util.customgui.CustomGui
+
+class StorageOverlayCustom(
+ val handler: StorageBackingHandle,
+ val screen: GenericContainerScreen,
+ val overview: StorageOverlayScreen,
+) : CustomGui() {
+ override fun onVoluntaryExit(): Boolean {
+ overview.isExiting = true
+ return super.onVoluntaryExit()
+ }
+
+ override fun getBounds(): List<Rectangle> {
+ return overview.getBounds()
+ }
+
+ override fun afterSlotRender(context: DrawContext, slot: Slot) {
+ if (slot.inventory !is PlayerInventory)
+ context.disableScissor()
+ }
+
+ override fun beforeSlotRender(context: DrawContext, slot: Slot) {
+ if (slot.inventory !is PlayerInventory)
+ overview.createScissors(context)
+ }
+
+ override fun onInit() {
+ overview.init(MinecraftClient.getInstance(), screen.width, screen.height)
+ overview.init()
+ screen as AccessorHandledScreen
+ screen.x_Firmament = overview.measurements.x
+ screen.y_Firmament = overview.measurements.y
+ screen.backgroundWidth_Firmament = overview.measurements.totalWidth
+ screen.backgroundHeight_Firmament = overview.measurements.totalHeight
+ }
+
+ override fun isPointOverSlot(slot: Slot, xOffset: Int, yOffset: Int, pointX: Double, pointY: Double): Boolean {
+ if (!super.isPointOverSlot(slot, xOffset, yOffset, pointX, pointY))
+ return false
+ if (slot.inventory !is PlayerInventory) {
+ if (!overview.getScrollPanelInner().contains(pointX, pointY))
+ return false
+ }
+ return true
+ }
+
+ override fun shouldDrawForeground(): Boolean {
+ return false
+ }
+
+ override fun mouseClick(mouseX: Double, mouseY: Double, button: Int): Boolean {
+ return overview.mouseClicked(mouseX, mouseY, button, (handler as? StorageBackingHandle.Page)?.storagePageSlot)
+ }
+
+ override fun render(drawContext: DrawContext, delta: Float, mouseX: Int, mouseY: Int) {
+ overview.drawBackgrounds(drawContext)
+ overview.drawPages(drawContext,
+ mouseX,
+ mouseY,
+ delta,
+ (handler as? StorageBackingHandle.Page)?.storagePageSlot,
+ screen.screenHandler.slots.take(screen.screenHandler.rows * 9).drop(9),
+ Point((screen as AccessorHandledScreen).x_Firmament, screen.y_Firmament))
+ overview.drawScrollBar(drawContext)
+ }
+
+ override fun moveSlot(slot: Slot) {
+ val index = slot.index
+ if (index in 0..<36) {
+ val (x, y) = overview.getPlayerInventorySlotPosition(index)
+ slot.x = x - (screen as AccessorHandledScreen).x_Firmament
+ slot.y = y - screen.y_Firmament
+ } else {
+ slot.x = -100000
+ slot.y = -100000
+ }
+ }
+
+ override fun mouseScrolled(
+ mouseX: Double,
+ mouseY: Double,
+ horizontalAmount: Double,
+ verticalAmount: Double
+ ): Boolean {
+ return overview.mouseScrolled(mouseX, mouseY, horizontalAmount, verticalAmount)
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayScreen.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayScreen.kt
new file mode 100644
index 0000000..13c6974
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StorageOverlayScreen.kt
@@ -0,0 +1,296 @@
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import me.shedaniel.math.Point
+import me.shedaniel.math.Rectangle
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.screen.Screen
+import net.minecraft.screen.slot.Slot
+import net.minecraft.text.Text
+import net.minecraft.util.Identifier
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.CommandEvent
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.ScreenUtil
+import moe.nea.firmament.util.assertTrueOr
+
+class StorageOverlayScreen : Screen(Text.literal("")) {
+
+ companion object {
+ val PLAYER_WIDTH = 184
+ val PLAYER_HEIGHT = 91
+ val PLAYER_Y_INSET = 3
+ val SLOT_SIZE = 18
+ val PADDING = 10
+ val PAGE_WIDTH = SLOT_SIZE * 9
+ val HOTBAR_X = 12
+ val HOTBAR_Y = 67
+ val MAIN_INVENTORY_Y = 9
+ val SCROLL_BAR_WIDTH = 8
+ val SCROLL_BAR_HEIGHT = 16
+ }
+
+ var isExiting: Boolean = false
+ var scroll: Float = 0F
+ var pageWidthCount = StorageOverlay.TConfig.columns
+
+ inner class Measurements {
+ val innerScrollPanelWidth = PAGE_WIDTH * pageWidthCount + (pageWidthCount - 1) * PADDING
+ val overviewWidth = innerScrollPanelWidth + 3 * PADDING + SCROLL_BAR_WIDTH
+ val x = width / 2 - overviewWidth / 2
+ val overviewHeight = minOf(3 * 18 * 6, height - PLAYER_HEIGHT - minOf(80, height / 10))
+ val innerScrollPanelHeight = overviewHeight - PADDING * 2
+ val y = height / 2 - (overviewHeight + PLAYER_HEIGHT) / 2
+ val playerX = width / 2 - PLAYER_WIDTH / 2
+ val playerY = y + overviewHeight - PLAYER_Y_INSET
+ val totalWidth = overviewWidth
+ val totalHeight = overviewHeight - PLAYER_Y_INSET + PLAYER_HEIGHT
+ }
+
+ var measurements = Measurements()
+
+ var lastRenderedInnerHeight = 0
+ public override fun init() {
+ super.init()
+ pageWidthCount = StorageOverlay.TConfig.columns
+ .coerceAtMost((width - PADDING) / (PAGE_WIDTH + PADDING))
+ .coerceAtLeast(1)
+ measurements = Measurements()
+ }
+
+ override fun mouseScrolled(
+ mouseX: Double,
+ mouseY: Double,
+ horizontalAmount: Double,
+ verticalAmount: Double
+ ): Boolean {
+ scroll = (scroll + StorageOverlay.adjustScrollSpeed(verticalAmount)).toFloat()
+ .coerceAtMost(getMaxScroll())
+ .coerceAtLeast(0F)
+ return true
+ }
+
+ fun getMaxScroll() = lastRenderedInnerHeight.toFloat() - getScrollPanelInner().height
+
+ val playerInventorySprite = Identifier.of("firmament:storageoverlay/player_inventory")
+ val upperBackgroundSprite = Identifier.of("firmament:storageoverlay/upper_background")
+ val slotRowSprite = Identifier.of("firmament:storageoverlay/storage_row")
+ val scrollbarBackground = Identifier.of("firmament:storageoverlay/scroll_bar_background")
+ val scrollbarKnob = Identifier.of("firmament:storageoverlay/scroll_bar_knob")
+
+ override fun close() {
+ isExiting = true
+ super.close()
+ }
+
+ override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+ super.render(context, mouseX, mouseY, delta)
+ drawBackgrounds(context)
+ drawPages(context, mouseX, mouseY, delta, null, null, Point())
+ drawScrollBar(context)
+ drawPlayerInventory(context, mouseX, mouseY, delta)
+ }
+
+ fun getScrollbarPercentage(): Float {
+ return scroll / getMaxScroll()
+ }
+
+ fun drawScrollBar(context: DrawContext) {
+ val sbRect = getScrollBarRect()
+ context.drawGuiTexture(
+ scrollbarBackground,
+ sbRect.minX, sbRect.minY,
+ sbRect.width, sbRect.height,
+ )
+ context.drawGuiTexture(
+ scrollbarKnob,
+ sbRect.minX, sbRect.minY + (getScrollbarPercentage() * (sbRect.height - SCROLL_BAR_HEIGHT)).toInt(),
+ SCROLL_BAR_WIDTH, SCROLL_BAR_HEIGHT
+ )
+ }
+
+ fun drawBackgrounds(context: DrawContext) {
+ context.drawGuiTexture(upperBackgroundSprite,
+ measurements.x,
+ measurements.y,
+ 0,
+ measurements.overviewWidth,
+ measurements.overviewHeight)
+ context.drawGuiTexture(playerInventorySprite,
+ measurements.playerX,
+ measurements.playerY,
+ 0,
+ PLAYER_WIDTH,
+ PLAYER_HEIGHT)
+ }
+
+ fun getPlayerInventorySlotPosition(int: Int): Pair<Int, Int> {
+ if (int < 9) {
+ return Pair(measurements.playerX + int * SLOT_SIZE + HOTBAR_X, HOTBAR_Y + measurements.playerY)
+ }
+ return Pair(
+ measurements.playerX + (int % 9) * SLOT_SIZE + HOTBAR_X,
+ measurements.playerY + (int / 9 - 1) * SLOT_SIZE + MAIN_INVENTORY_Y
+ )
+ }
+
+ fun drawPlayerInventory(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+ val items = MC.player?.inventory?.main ?: return
+ items.withIndex().forEach { (index, item) ->
+ val (x, y) = getPlayerInventorySlotPosition(index)
+ context.drawItem(item, x, y, 0)
+ context.drawItemInSlot(textRenderer, item, x, y)
+ }
+ }
+
+ fun getScrollBarRect(): Rectangle {
+ return Rectangle(measurements.x + PADDING + measurements.innerScrollPanelWidth + PADDING,
+ measurements.y + PADDING,
+ SCROLL_BAR_WIDTH,
+ measurements.innerScrollPanelHeight)
+ }
+
+ fun getScrollPanelInner(): Rectangle {
+ return Rectangle(measurements.x + PADDING,
+ measurements.y + PADDING,
+ measurements.innerScrollPanelWidth,
+ measurements.innerScrollPanelHeight)
+ }
+
+ fun createScissors(context: DrawContext) {
+ val rect = getScrollPanelInner()
+ context.enableScissor(
+ rect.minX, rect.minY,
+ rect.maxX, rect.maxY
+ )
+ }
+
+ fun drawPages(
+ context: DrawContext, mouseX: Int, mouseY: Int, delta: Float,
+ excluding: StoragePageSlot?,
+ slots: List<Slot>?,
+ slotOffset: Point
+ ) {
+ createScissors(context)
+ val data = StorageOverlay.Data.data ?: StorageData()
+ layoutedForEach(data) { rect, page, inventory ->
+ drawPage(context,
+ rect.x,
+ rect.y,
+ page, inventory,
+ if (excluding == page) slots else null,
+ slotOffset
+ )
+ }
+ context.disableScissor()
+ }
+
+ override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean {
+ return mouseClicked(mouseX, mouseY, button, null)
+ }
+
+ fun mouseClicked(mouseX: Double, mouseY: Double, button: Int, activePage: StoragePageSlot?): Boolean {
+ if (getScrollPanelInner().contains(mouseX, mouseY)) {
+ val data = StorageOverlay.Data.data ?: StorageData()
+ layoutedForEach(data) { rect, page, _ ->
+ if (rect.contains(mouseX, mouseY) && activePage != page && button == 0) {
+ page.navigateTo()
+ return true
+ }
+ }
+ return false
+ }
+ val sbRect = getScrollBarRect()
+ if (sbRect.contains(mouseX, mouseY)) {
+ // TODO: support dragging of the mouse and such
+ val percentage = (mouseY - sbRect.getY()) / sbRect.getHeight()
+ scroll = (getMaxScroll() * percentage).toFloat()
+ mouseScrolled(0.0, 0.0, 0.0, 0.0)
+ return true
+ }
+ return false
+ }
+
+ private inline fun layoutedForEach(
+ data: StorageData,
+ func: (
+ rectangle: Rectangle,
+ page: StoragePageSlot, inventory: StorageData.StorageInventory,
+ ) -> Unit
+ ) {
+ var yOffset = -scroll.toInt()
+ var xOffset = 0
+ var maxHeight = 0
+ for ((page, inventory) in data.storageInventories.entries) {
+ val currentHeight = inventory.inventory?.let { it.rows * SLOT_SIZE + 4 + textRenderer.fontHeight }
+ ?: 18
+ maxHeight = maxOf(maxHeight, currentHeight)
+ val rect = Rectangle(
+ measurements.x + PADDING + (PAGE_WIDTH + PADDING) * xOffset,
+ yOffset + measurements.y + PADDING,
+ PAGE_WIDTH,
+ currentHeight
+ )
+ func(rect, page, inventory)
+ xOffset++
+ if (xOffset >= pageWidthCount) {
+ yOffset += maxHeight
+ xOffset = 0
+ maxHeight = 0
+ }
+ }
+ lastRenderedInnerHeight = maxHeight + yOffset + scroll.toInt()
+ }
+
+ fun drawPage(
+ context: DrawContext,
+ x: Int,
+ y: Int,
+ page: StoragePageSlot,
+ inventory: StorageData.StorageInventory,
+ slots: List<Slot>?,
+ slotOffset: Point,
+ ): Int {
+ val inv = inventory.inventory
+ if (inv == null) {
+ context.drawGuiTexture(upperBackgroundSprite, x, y, PAGE_WIDTH, 18)
+ context.drawText(textRenderer,
+ Text.literal("TODO: open this page"),
+ x + 4,
+ y + 4,
+ -1,
+ true)
+ return 18
+ }
+ assertTrueOr(slots == null || slots.size == inv.stacks.size) { return 0 }
+ val name = page.defaultName()
+ context.drawText(textRenderer, Text.literal(name), x + 4, y + 2,
+ if (slots == null) 0xFFFFFFFF.toInt() else 0xFFFFFF00.toInt(), true)
+ context.drawGuiTexture(slotRowSprite, x, y + 4 + textRenderer.fontHeight, PAGE_WIDTH, inv.rows * SLOT_SIZE)
+ inv.stacks.forEachIndexed { index, stack ->
+ val slotX = (index % 9) * SLOT_SIZE + x + 1
+ val slotY = (index / 9) * SLOT_SIZE + y + 4 + textRenderer.fontHeight + 1
+ if (slots == null) {
+ context.drawItem(stack, slotX, slotY)
+ context.drawItemInSlot(textRenderer, stack, slotX, slotY)
+ } else {
+ val slot = slots[index]
+ slot.x = slotX - slotOffset.x
+ slot.y = slotY - slotOffset.y
+ }
+ }
+ return inv.rows * SLOT_SIZE + 4 + textRenderer.fontHeight
+ }
+
+ fun getBounds(): List<Rectangle> {
+ return listOf(
+ Rectangle(measurements.x,
+ measurements.y,
+ measurements.overviewWidth,
+ measurements.overviewHeight),
+ Rectangle(measurements.playerX,
+ measurements.playerY,
+ PLAYER_WIDTH,
+ PLAYER_HEIGHT))
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StorageOverviewScreen.kt b/src/main/kotlin/features/inventory/storageoverlay/StorageOverviewScreen.kt
new file mode 100644
index 0000000..2cbd54e
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StorageOverviewScreen.kt
@@ -0,0 +1,123 @@
+
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import org.lwjgl.glfw.GLFW
+import kotlin.math.max
+import net.minecraft.block.Blocks
+import net.minecraft.client.gui.DrawContext
+import net.minecraft.client.gui.screen.Screen
+import net.minecraft.item.Item
+import net.minecraft.item.Items
+import net.minecraft.text.Text
+import net.minecraft.util.DyeColor
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.toShedaniel
+
+class StorageOverviewScreen() : Screen(Text.empty()) {
+ companion object {
+ val emptyStorageSlotItems = listOf<Item>(
+ Blocks.RED_STAINED_GLASS_PANE.asItem(),
+ Blocks.BROWN_STAINED_GLASS_PANE.asItem(),
+ Items.GRAY_DYE
+ )
+ val pageWidth get() = 19 * 9
+ }
+
+ val content = StorageOverlay.Data.data ?: StorageData()
+ var isClosing = false
+
+ var scroll = 0
+ var lastRenderedHeight = 0
+
+ override fun render(context: DrawContext, mouseX: Int, mouseY: Int, delta: Float) {
+ super.render(context, mouseX, mouseY, delta)
+ context.fill(0, 0, width, height, 0x90000000.toInt())
+ layoutedForEach { (key, value), offsetX, offsetY ->
+ context.matrices.push()
+ context.matrices.translate(offsetX.toFloat(), offsetY.toFloat(), 0F)
+ renderStoragePage(context, value, mouseX - offsetX, mouseY - offsetY)
+ context.matrices.pop()
+ }
+ }
+
+ inline fun layoutedForEach(onEach: (data: Pair<StoragePageSlot, StorageData.StorageInventory>, offsetX: Int, offsetY: Int) -> Unit) {
+ var offsetY = 0
+ var currentMaxHeight = StorageOverlay.config.margin - StorageOverlay.config.padding - scroll
+ var totalHeight = -currentMaxHeight
+ content.storageInventories.onEachIndexed { index, (key, value) ->
+ val pageX = (index % StorageOverlay.config.columns)
+ if (pageX == 0) {
+ currentMaxHeight += StorageOverlay.config.padding
+ offsetY += currentMaxHeight
+ totalHeight += currentMaxHeight
+ currentMaxHeight = 0
+ }
+ val xPosition =
+ width / 2 - (StorageOverlay.config.columns * (pageWidth + StorageOverlay.config.padding) - StorageOverlay.config.padding) / 2 + pageX * (pageWidth + StorageOverlay.config.padding)
+ onEach(Pair(key, value), xPosition, offsetY)
+ val height = getStorePageHeight(value)
+ currentMaxHeight = max(currentMaxHeight, height)
+ }
+ lastRenderedHeight = totalHeight + currentMaxHeight
+ }
+
+ override fun mouseClicked(mouseX: Double, mouseY: Double, button: Int): Boolean {
+ layoutedForEach { (k, p), x, y ->
+ val rx = mouseX - x
+ val ry = mouseY - y
+ if (rx in (0.0..pageWidth.toDouble()) && ry in (0.0..getStorePageHeight(p).toDouble())) {
+ close()
+ StorageOverlay.lastStorageOverlay = this
+ k.navigateTo()
+ return true
+ }
+ }
+ return super.mouseClicked(mouseX, mouseY, button)
+ }
+
+ fun getStorePageHeight(page: StorageData.StorageInventory): Int {
+ return page.inventory?.rows?.let { it * 19 + MC.font.fontHeight + 2 } ?: 60
+ }
+
+ override fun mouseScrolled(
+ mouseX: Double,
+ mouseY: Double,
+ horizontalAmount: Double,
+ verticalAmount: Double
+ ): Boolean {
+ scroll =
+ (scroll + StorageOverlay.adjustScrollSpeed(verticalAmount)).toInt()
+ .coerceAtMost(lastRenderedHeight - height + 2 * StorageOverlay.config.margin).coerceAtLeast(0)
+ return true
+ }
+
+ private fun renderStoragePage(context: DrawContext, page: StorageData.StorageInventory, mouseX: Int, mouseY: Int) {
+ context.drawText(MC.font, page.title, 2, 2, -1, true)
+ val inventory = page.inventory
+ if (inventory == null) {
+ // TODO: Missing texture
+ context.fill(0, 0, pageWidth, 60, DyeColor.RED.toShedaniel().darker(4.0).color)
+ context.drawCenteredTextWithShadow(MC.font, Text.literal("Not loaded yet"), pageWidth / 2, 30, -1)
+ return
+ }
+
+ for ((index, stack) in inventory.stacks.withIndex()) {
+ val x = (index % 9) * 19
+ val y = (index / 9) * 19 + MC.font.fontHeight + 2
+ if (((mouseX - x) in 0 until 18) && ((mouseY - y) in 0 until 18)) {
+ context.fill(x, y, x + 18, y + 18, 0x80808080.toInt())
+ } else {
+ context.fill(x, y, x + 18, y + 18, 0x40808080.toInt())
+ }
+ context.drawItem(stack, x + 1, y + 1)
+ context.drawItemInSlot(MC.font, stack, x + 1, y + 1)
+ }
+ }
+
+ override fun keyPressed(keyCode: Int, scanCode: Int, modifiers: Int): Boolean {
+ if (keyCode == GLFW.GLFW_KEY_ESCAPE)
+ isClosing = true
+ return super.keyPressed(keyCode, scanCode, modifiers)
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/StoragePageSlot.kt b/src/main/kotlin/features/inventory/storageoverlay/StoragePageSlot.kt
new file mode 100644
index 0000000..9259415
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/StoragePageSlot.kt
@@ -0,0 +1,66 @@
+
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import moe.nea.firmament.util.MC
+
+@Serializable(with = StoragePageSlot.Serializer::class)
+data class StoragePageSlot(val index: Int) : Comparable<StoragePageSlot> {
+ object Serializer : KSerializer<StoragePageSlot> {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("StoragePageSlot", PrimitiveKind.INT)
+
+ override fun deserialize(decoder: Decoder): StoragePageSlot {
+ return StoragePageSlot(decoder.decodeInt())
+ }
+
+ override fun serialize(encoder: Encoder, value: StoragePageSlot) {
+ encoder.encodeInt(value.index)
+ }
+ }
+
+ init {
+ assert(index in 0 until (3 * 9))
+ }
+
+ val isEnderChest get() = index < 9
+ val isBackPack get() = !isEnderChest
+ val slotIndexInOverviewPage get() = if (isEnderChest) index + 9 else index + 18
+ fun defaultName(): String = if (isEnderChest) "Ender Chest #${index + 1}" else "Backpack #${index - 9 + 1}"
+
+ fun navigateTo() {
+ if (isBackPack) {
+ MC.sendCommand("backpack ${index - 9 + 1}")
+ } else {
+ MC.sendCommand("enderchest ${index + 1}")
+ }
+ }
+
+ companion object {
+ fun fromOverviewSlotIndex(slot: Int): StoragePageSlot? {
+ if (slot in 9 until 18) return StoragePageSlot(slot - 9)
+ if (slot in 27 until 45) return StoragePageSlot(slot - 27 + 9)
+ return null
+ }
+
+ fun ofEnderChestPage(slot: Int): StoragePageSlot {
+ assert(slot in 1..9)
+ return StoragePageSlot(slot - 1)
+ }
+
+ fun ofBackPackPage(slot: Int): StoragePageSlot {
+ assert(slot in 1..18)
+ return StoragePageSlot(slot - 1 + 9)
+ }
+ }
+
+ override fun compareTo(other: StoragePageSlot): Int {
+ return this.index - other.index
+ }
+}
diff --git a/src/main/kotlin/features/inventory/storageoverlay/VirtualInventory.kt b/src/main/kotlin/features/inventory/storageoverlay/VirtualInventory.kt
new file mode 100644
index 0000000..e07df8a
--- /dev/null
+++ b/src/main/kotlin/features/inventory/storageoverlay/VirtualInventory.kt
@@ -0,0 +1,65 @@
+
+
+package moe.nea.firmament.features.inventory.storageoverlay
+
+import io.ktor.util.decodeBase64Bytes
+import io.ktor.util.encodeBase64
+import java.io.ByteArrayInputStream
+import java.io.ByteArrayOutputStream
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import net.minecraft.item.ItemStack
+import net.minecraft.nbt.NbtCompound
+import net.minecraft.nbt.NbtIo
+import net.minecraft.nbt.NbtList
+import net.minecraft.nbt.NbtOps
+import net.minecraft.nbt.NbtSizeTracker
+
+@Serializable(with = VirtualInventory.Serializer::class)
+data class VirtualInventory(
+ val stacks: List<ItemStack>
+) {
+ val rows = stacks.size / 9
+
+ init {
+ assert(stacks.size % 9 == 0)
+ assert(stacks.size / 9 in 1..5)
+ }
+
+
+ object Serializer : KSerializer<VirtualInventory> {
+ const val INVENTORY = "INVENTORY"
+ override val descriptor: SerialDescriptor
+ get() = PrimitiveSerialDescriptor("VirtualInventory", PrimitiveKind.STRING)
+
+ override fun deserialize(decoder: Decoder): VirtualInventory {
+ val s = decoder.decodeString()
+ val n = NbtIo.readCompressed(ByteArrayInputStream(s.decodeBase64Bytes()), NbtSizeTracker.of(100_000_000))
+ val items = n.getList(INVENTORY, NbtCompound.COMPOUND_TYPE.toInt())
+ return VirtualInventory(items.map {
+ it as NbtCompound
+ if (it.isEmpty) ItemStack.EMPTY
+ else runCatching {
+ ItemStack.CODEC.parse(NbtOps.INSTANCE, it).orThrow
+ }.getOrElse { ItemStack.EMPTY }
+ })
+ }
+
+ override fun serialize(encoder: Encoder, value: VirtualInventory) {
+ val list = NbtList()
+ value.stacks.forEach {
+ if (it.isEmpty) list.add(NbtCompound())
+ else list.add(runCatching { ItemStack.CODEC.encode(it, NbtOps.INSTANCE, NbtCompound()).orThrow }
+ .getOrElse { NbtCompound() })
+ }
+ val baos = ByteArrayOutputStream()
+ NbtIo.writeCompressed(NbtCompound().also { it.put(INVENTORY, list) }, baos)
+ encoder.encodeString(baos.toByteArray().encodeBase64())
+ }
+ }
+}
diff --git a/src/main/kotlin/features/mining/Histogram.kt b/src/main/kotlin/features/mining/Histogram.kt
new file mode 100644
index 0000000..ed48437
--- /dev/null
+++ b/src/main/kotlin/features/mining/Histogram.kt
@@ -0,0 +1,81 @@
+
+package moe.nea.firmament.features.mining
+
+import java.util.*
+import kotlin.time.Duration
+import moe.nea.firmament.util.TimeMark
+
+class Histogram<T>(
+ val maxSize: Int,
+ val maxDuration: Duration,
+) {
+
+ data class OrderedTimestamp(val timestamp: TimeMark, val order: Int) : Comparable<OrderedTimestamp> {
+ override fun compareTo(other: OrderedTimestamp): Int {
+ val o = timestamp.compareTo(other.timestamp)
+ if (o != 0) return o
+ return order.compareTo(other.order)
+ }
+ }
+
+ val size: Int get() = dataPoints.size
+ private val dataPoints: NavigableMap<OrderedTimestamp, T> = TreeMap()
+
+ private var order = Int.MIN_VALUE
+
+ fun record(entry: T, timestamp: TimeMark = TimeMark.now()) {
+ dataPoints[OrderedTimestamp(timestamp, order++)] = entry
+ trim()
+ }
+
+ fun oldestUpdate(): TimeMark {
+ trim()
+ return if (dataPoints.isEmpty()) TimeMark.now() else dataPoints.firstKey().timestamp
+ }
+
+ fun latestUpdate(): TimeMark {
+ trim()
+ return if (dataPoints.isEmpty()) TimeMark.farPast() else dataPoints.lastKey().timestamp
+ }
+
+ fun averagePer(valueExtractor: (T) -> Double, perDuration: Duration): Double? {
+ return aggregate(
+ seed = 0.0,
+ operator = { accumulator, entry, _ -> accumulator + valueExtractor(entry) },
+ finish = { sum, beginning, end ->
+ val timespan = end - beginning
+ if (timespan > perDuration)
+ sum / (timespan / perDuration)
+ else null
+ })
+ }
+
+ fun <V, R> aggregate(
+ seed: V,
+ operator: (V, T, TimeMark) -> V,
+ finish: (V, TimeMark, TimeMark) -> R
+ ): R? {
+ trim()
+ var accumulator = seed
+ var min: TimeMark? = null
+ var max: TimeMark? = null
+ dataPoints.forEach { (key, value) ->
+ max = key.timestamp
+ if (min == null)
+ min = key.timestamp
+ accumulator = operator(accumulator, value, key.timestamp)
+ }
+ if (min == null)
+ return null
+ return finish(accumulator, min!!, max!!)
+ }
+
+ private fun trim() {
+ while (maxSize < dataPoints.size) {
+ dataPoints.pollFirstEntry()
+ }
+ dataPoints.headMap(OrderedTimestamp(TimeMark.ago(maxDuration), Int.MAX_VALUE)).clear()
+ }
+
+
+}
diff --git a/src/main/kotlin/features/mining/PickaxeAbility.kt b/src/main/kotlin/features/mining/PickaxeAbility.kt
new file mode 100644
index 0000000..7879f2d
--- /dev/null
+++ b/src/main/kotlin/features/mining/PickaxeAbility.kt
@@ -0,0 +1,176 @@
+
+package moe.nea.firmament.features.mining
+
+import java.util.regex.Pattern
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import net.minecraft.item.ItemStack
+import net.minecraft.util.DyeColor
+import net.minecraft.util.Hand
+import net.minecraft.util.Identifier
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.HudRenderEvent
+import moe.nea.firmament.events.ProcessChatEvent
+import moe.nea.firmament.events.SlotClickEvent
+import moe.nea.firmament.events.WorldReadyEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.DurabilityBarEvent
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.SHORT_NUMBER_FORMAT
+import moe.nea.firmament.util.TIME_PATTERN
+import moe.nea.firmament.util.TimeMark
+import moe.nea.firmament.util.extraAttributes
+import moe.nea.firmament.util.item.displayNameAccordingToNbt
+import moe.nea.firmament.util.item.loreAccordingToNbt
+import moe.nea.firmament.util.parseShortNumber
+import moe.nea.firmament.util.parseTimePattern
+import moe.nea.firmament.util.render.RenderCircleProgress
+import moe.nea.firmament.util.render.lerp
+import moe.nea.firmament.util.toShedaniel
+import moe.nea.firmament.util.unformattedString
+import moe.nea.firmament.util.useMatch
+
+object PickaxeAbility : FirmamentFeature {
+ override val identifier: String
+ get() = "pickaxe-info"
+
+
+ object TConfig : ManagedConfig(identifier) {
+ val cooldownEnabled by toggle("ability-cooldown") { true }
+ val cooldownScale by integer("ability-scale", 16, 64) { 16 }
+ val drillFuelBar by toggle("fuel-bar") { true }
+ }
+
+ var lobbyJoinTime = TimeMark.farPast()
+ var lastUsage = mutableMapOf<String, TimeMark>()
+ var abilityOverride: String? = null
+ var defaultAbilityDurations = mutableMapOf<String, Duration>(
+ "Mining Speed Boost" to 120.seconds,
+ "Pickobulus" to 110.seconds,
+ "Gemstone Infusion" to 140.seconds,
+ "Hazardous Miner" to 140.seconds,
+ "Maniac Miner" to 59.seconds,
+ "Vein Seeker" to 60.seconds
+ )
+
+ override val config: ManagedConfig
+ get() = TConfig
+
+ fun getCooldownPercentage(name: String, cooldown: Duration): Double {
+ val sinceLastUsage = lastUsage[name]?.passedTime() ?: Duration.INFINITE
+ if (sinceLastUsage < cooldown)
+ return sinceLastUsage / cooldown
+ val sinceLobbyJoin = lobbyJoinTime.passedTime()
+ val halfCooldown = cooldown / 2
+ if (sinceLobbyJoin < halfCooldown) {
+ return (sinceLobbyJoin / halfCooldown)
+ }
+ return 1.0
+ }
+
+ @Subscribe
+ fun onSlotClick(it: SlotClickEvent) {
+ if (MC.screen?.title?.unformattedString == "Heart of the Mountain") {
+ val name = it.stack.displayNameAccordingToNbt?.unformattedString ?: return
+ val cooldown = it.stack.loreAccordingToNbt.firstNotNullOfOrNull {
+ cooldownPattern.useMatch(it.unformattedString) {
+ parseTimePattern(group("cooldown"))
+ }
+ } ?: return
+ defaultAbilityDurations[name] = cooldown
+ }
+ }
+
+ @Subscribe
+ fun onDurabilityBar(it: DurabilityBarEvent) {
+ if (!TConfig.drillFuelBar) return
+ val lore = it.item.loreAccordingToNbt
+ if (lore.lastOrNull()?.unformattedString?.contains("DRILL") != true) return
+ val maxFuel = lore.firstNotNullOfOrNull {
+ fuelPattern.useMatch(it.unformattedString) {
+ parseShortNumber(group("maxFuel"))
+ }
+ } ?: return
+ val extra = it.item.extraAttributes
+ if (!extra.contains("drill_fuel")) return
+ val fuel = extra.getInt("drill_fuel")
+ val percentage = fuel / maxFuel.toFloat()
+ it.barOverride = DurabilityBarEvent.DurabilityBar(
+ lerp(
+ DyeColor.RED.toShedaniel(),
+ DyeColor.GREEN.toShedaniel(),
+ percentage
+ ), percentage
+ )
+ }
+
+ @Subscribe
+ fun onChatMessage(it: ProcessChatEvent) {
+ abilityUsePattern.useMatch(it.unformattedString) {
+ lastUsage[group("name")] = TimeMark.now()
+ }
+ abilitySwitchPattern.useMatch(it.unformattedString) {
+ abilityOverride = group("ability")
+ }
+ }
+
+ @Subscribe
+ fun onWorldReady(event: WorldReadyEvent) {
+ lastUsage.clear()
+ lobbyJoinTime = TimeMark.now()
+ abilityOverride = null
+ }
+
+ val abilityUsePattern = Pattern.compile("You used your (?<name>.*) Pickaxe Ability!")
+ val fuelPattern = Pattern.compile("Fuel: .*/(?<maxFuel>$SHORT_NUMBER_FORMAT)")
+
+ data class PickaxeAbilityData(
+ val name: String,
+ val cooldown: Duration,
+ )
+
+ fun getCooldownFromLore(itemStack: ItemStack): PickaxeAbilityData? {
+ val lore = itemStack.loreAccordingToNbt
+ if (!lore.any { it.unformattedString.contains("Breaking Power") == true })
+ return null
+ val cooldown = lore.firstNotNullOfOrNull {
+ cooldownPattern.useMatch(it.unformattedString) {
+ parseTimePattern(group("cooldown"))
+ }
+ } ?: return null
+ val name = lore.firstNotNullOfOrNull {
+ abilityPattern.useMatch(it.unformattedString) {
+ group("name")
+ }
+ } ?: return null
+ return PickaxeAbilityData(name, cooldown)
+ }
+
+
+ val cooldownPattern = Pattern.compile("Cooldown: (?<cooldown>$TIME_PATTERN)")
+ val abilityPattern = Pattern.compile("Ability: (?<name>.*) {2}RIGHT CLICK")
+ val abilitySwitchPattern =
+ Pattern.compile("You selected (?<ability>.*) as your Pickaxe Ability\\. This ability will apply to all of your pickaxes!")
+
+
+ @Subscribe
+ fun renderHud(event: HudRenderEvent) {
+ if (!TConfig.cooldownEnabled) return
+ var ability = getCooldownFromLore(MC.player?.getStackInHand(Hand.MAIN_HAND) ?: return) ?: return
+ defaultAbilityDurations[ability.name] = ability.cooldown
+ val ao = abilityOverride
+ if (ao != ability.name && ao != null) {
+ ability = PickaxeAbilityData(ao, defaultAbilityDurations[ao] ?: 120.seconds)
+ }
+ event.context.matrices.push()
+ event.context.matrices.translate(MC.window.scaledWidth / 2F, MC.window.scaledHeight / 2F, 0F)
+ event.context.matrices.scale(TConfig.cooldownScale.toFloat(), TConfig.cooldownScale.toFloat(), 1F)
+ RenderCircleProgress.renderCircle(
+ event.context, Identifier.of("firmament", "textures/gui/circle.png"),
+ getCooldownPercentage(ability.name, ability.cooldown).toFloat(),
+ 0f, 1f, 0f, 1f
+ )
+ event.context.matrices.pop()
+ }
+}
diff --git a/src/main/kotlin/features/mining/PristineProfitTracker.kt b/src/main/kotlin/features/mining/PristineProfitTracker.kt
new file mode 100644
index 0000000..f1bc7e5
--- /dev/null
+++ b/src/main/kotlin/features/mining/PristineProfitTracker.kt
@@ -0,0 +1,133 @@
+
+package moe.nea.firmament.features.mining
+
+import io.github.notenoughupdates.moulconfig.xml.Bind
+import moe.nea.jarvis.api.Point
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.serializer
+import kotlin.time.Duration.Companion.seconds
+import net.minecraft.text.Text
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.ProcessChatEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.gui.hud.MoulConfigHud
+import moe.nea.firmament.util.BazaarPriceStrategy
+import moe.nea.firmament.util.FirmFormatters.formatCommas
+import moe.nea.firmament.util.SkyblockId
+import moe.nea.firmament.util.data.ProfileSpecificDataHolder
+import moe.nea.firmament.util.formattedString
+import moe.nea.firmament.util.parseIntWithComma
+import moe.nea.firmament.util.useMatch
+
+object PristineProfitTracker : FirmamentFeature {
+ override val identifier: String
+ get() = "pristine-profit"
+
+ enum class GemstoneKind(
+ val label: String,
+ val flawedId: SkyblockId,
+ ) {
+ SAPPHIRE("Sapphire", SkyblockId("FLAWED_SAPPHIRE_GEM")),
+ RUBY("Ruby", SkyblockId("FLAWED_RUBY_GEM")),
+ AMETHYST("Amethyst", SkyblockId("FLAWED_AMETHYST_GEM")),
+ AMBER("Amber", SkyblockId("FLAWED_AMBER_GEM")),
+ TOPAZ("Topaz", SkyblockId("FLAWED_TOPAZ_GEM")),
+ JADE("Jade", SkyblockId("FLAWED_JADE_GEM")),
+ JASPER("Jasper", SkyblockId("FLAWED_JASPER_GEM")),
+ OPAL("Opal", SkyblockId("FLAWED_OPAL_GEM")),
+ }
+
+ @Serializable
+ data class Data(
+ var maxMoneyPerSecond: Double = 1.0,
+ var maxCollectionPerSecond: Double = 1.0,
+ )
+
+ object DConfig : ProfileSpecificDataHolder<Data>(serializer(), identifier, ::Data)
+
+ override val config: ManagedConfig?
+ get() = TConfig
+
+ object TConfig : ManagedConfig(identifier) {
+ val timeout by duration("timeout", 0.seconds, 120.seconds) { 30.seconds }
+ val gui by position("position", 80, 30) { Point(0.05, 0.2) }
+ }
+
+ val sellingStrategy = BazaarPriceStrategy.SELL_ORDER
+
+ val pristineRegex =
+ "PRISTINE! You found . Flawed (?<kind>${
+ GemstoneKind.entries.joinToString("|") { it.label }
+ }) Gemstone x(?<count>[0-9,]+)!".toPattern()
+
+ val collectionHistogram = Histogram<Double>(10000, 180.seconds)
+ val moneyHistogram = Histogram<Double>(10000, 180.seconds)
+
+ object ProfitHud : MoulConfigHud("pristine_profit", TConfig.gui) {
+ @field:Bind
+ var moneyCurrent: Double = 0.0
+
+ @field:Bind
+ var moneyMax: Double = 1.0
+
+ @field:Bind
+ var moneyText = ""
+
+ @field:Bind
+ var collectionCurrent = 0.0
+
+ @field:Bind
+ var collectionMax = 1.0
+
+ @field:Bind
+ var collectionText = ""
+ override fun shouldRender(): Boolean = collectionHistogram.latestUpdate().passedTime() < TConfig.timeout
+ }
+
+ val SECONDS_PER_HOUR = 3600
+ val ROUGHS_PER_FLAWED = 80
+
+ fun updateUi() {
+ val collectionPerSecond = collectionHistogram.averagePer({ it }, 1.seconds)
+ val moneyPerSecond = moneyHistogram.averagePer({ it }, 1.seconds)
+ if (collectionPerSecond == null || moneyPerSecond == null) return
+ ProfitHud.collectionCurrent = collectionPerSecond
+ ProfitHud.collectionText = Text.stringifiedTranslatable("firmament.pristine-profit.collection",
+ formatCommas(collectionPerSecond * SECONDS_PER_HOUR,
+ 1)).formattedString()
+ ProfitHud.moneyCurrent = moneyPerSecond
+ ProfitHud.moneyText = Text.stringifiedTranslatable("firmament.pristine-profit.money",
+ formatCommas(moneyPerSecond * SECONDS_PER_HOUR, 1))
+ .formattedString()
+ val data = DConfig.data
+ if (data != null) {
+ if (data.maxCollectionPerSecond < collectionPerSecond && collectionHistogram.oldestUpdate()
+ .passedTime() > 30.seconds
+ ) {
+ data.maxCollectionPerSecond = collectionPerSecond
+ DConfig.markDirty()
+ }
+ if (data.maxMoneyPerSecond < moneyPerSecond && moneyHistogram.oldestUpdate().passedTime() > 30.seconds) {
+ data.maxMoneyPerSecond = moneyPerSecond
+ DConfig.markDirty()
+ }
+ ProfitHud.collectionMax = maxOf(data.maxCollectionPerSecond, collectionPerSecond)
+ ProfitHud.moneyMax = maxOf(data.maxMoneyPerSecond, moneyPerSecond)
+ }
+ }
+
+
+ @Subscribe
+ fun onMessage(it: ProcessChatEvent) {
+ pristineRegex.useMatch(it.unformattedString) {
+ val gemstoneKind = GemstoneKind.valueOf(group("kind").uppercase())
+ val flawedCount = parseIntWithComma(group("count"))
+ val moneyAmount = sellingStrategy.getSellPrice(gemstoneKind.flawedId) * flawedCount
+ moneyHistogram.record(moneyAmount)
+ val collectionAmount = flawedCount * ROUGHS_PER_FLAWED
+ collectionHistogram.record(collectionAmount.toDouble())
+ updateUi()
+ }
+ }
+}
diff --git a/src/main/kotlin/features/notifications/Notifications.kt b/src/main/kotlin/features/notifications/Notifications.kt
new file mode 100644
index 0000000..8d912d1
--- /dev/null
+++ b/src/main/kotlin/features/notifications/Notifications.kt
@@ -0,0 +1,7 @@
+
+package moe.nea.firmament.features.notifications
+
+import moe.nea.firmament.features.FirmamentFeature
+
+object Notifications {
+}
diff --git a/src/main/kotlin/features/texturepack/AlwaysPredicate.kt b/src/main/kotlin/features/texturepack/AlwaysPredicate.kt
new file mode 100644
index 0000000..4dd28df
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/AlwaysPredicate.kt
@@ -0,0 +1,17 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonElement
+import net.minecraft.item.ItemStack
+
+object AlwaysPredicate : FirmamentModelPredicate {
+ override fun test(stack: ItemStack): Boolean {
+ return true
+ }
+
+ object Parser : FirmamentModelPredicateParser {
+ override fun parse(jsonElement: JsonElement): FirmamentModelPredicate {
+ return AlwaysPredicate
+ }
+ }
+}
diff --git a/src/main/kotlin/features/texturepack/AndPredicate.kt b/src/main/kotlin/features/texturepack/AndPredicate.kt
new file mode 100644
index 0000000..55a4f32
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/AndPredicate.kt
@@ -0,0 +1,26 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonArray
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import net.minecraft.item.ItemStack
+
+class AndPredicate(val children: Array<FirmamentModelPredicate>) : FirmamentModelPredicate {
+ override fun test(stack: ItemStack): Boolean {
+ return children.all { it.test(stack) }
+ }
+
+ object Parser : FirmamentModelPredicateParser {
+ override fun parse(jsonElement: JsonElement): FirmamentModelPredicate {
+ val children =
+ (jsonElement as JsonArray)
+ .flatMap {
+ CustomModelOverrideParser.parsePredicates(it as JsonObject)
+ }
+ .toTypedArray()
+ return AndPredicate(children)
+ }
+
+ }
+}
diff --git a/src/main/kotlin/features/texturepack/BakedModelExtra.kt b/src/main/kotlin/features/texturepack/BakedModelExtra.kt
new file mode 100644
index 0000000..ae1f6d5
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/BakedModelExtra.kt
@@ -0,0 +1,9 @@
+
+package moe.nea.firmament.features.texturepack
+
+import net.minecraft.client.render.model.BakedModel
+
+interface BakedModelExtra {
+ fun getHeadModel_firmament(): BakedModel?
+ fun setHeadModel_firmament(headModel: BakedModel?)
+}
diff --git a/src/main/kotlin/features/texturepack/BakedOverrideData.kt b/src/main/kotlin/features/texturepack/BakedOverrideData.kt
new file mode 100644
index 0000000..c012883
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/BakedOverrideData.kt
@@ -0,0 +1,8 @@
+
+package moe.nea.firmament.features.texturepack
+
+interface BakedOverrideData {
+ fun getFirmamentOverrides(): Array<FirmamentModelPredicate>?
+ fun setFirmamentOverrides(overrides: Array<FirmamentModelPredicate>?)
+
+}
diff --git a/src/main/kotlin/features/texturepack/CustomBlockTextures.kt b/src/main/kotlin/features/texturepack/CustomBlockTextures.kt
new file mode 100644
index 0000000..0f2c2e6
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/CustomBlockTextures.kt
@@ -0,0 +1,295 @@
+@file:UseSerializers(BlockPosSerializer::class, IdentifierSerializer::class)
+
+package moe.nea.firmament.features.texturepack
+
+import java.util.concurrent.CompletableFuture
+import net.fabricmc.loader.api.FabricLoader
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
+import kotlinx.serialization.UseSerializers
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.JsonDecoder
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.serializer
+import kotlin.jvm.optionals.getOrNull
+import net.minecraft.block.Block
+import net.minecraft.block.BlockState
+import net.minecraft.client.render.model.BakedModel
+import net.minecraft.client.util.ModelIdentifier
+import net.minecraft.registry.RegistryKey
+import net.minecraft.registry.RegistryKeys
+import net.minecraft.resource.ResourceManager
+import net.minecraft.resource.SinglePreparationResourceReloader
+import net.minecraft.util.Identifier
+import net.minecraft.util.math.BlockPos
+import net.minecraft.util.profiler.Profiler
+import moe.nea.firmament.Firmament
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.BakeExtraModelsEvent
+import moe.nea.firmament.events.EarlyResourceReloadEvent
+import moe.nea.firmament.events.FinalizeResourceManagerEvent
+import moe.nea.firmament.events.SkyblockServerUpdateEvent
+import moe.nea.firmament.features.texturepack.CustomGlobalTextures.logger
+import moe.nea.firmament.util.IdentifierSerializer
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.SBData
+import moe.nea.firmament.util.SkyBlockIsland
+import moe.nea.firmament.util.json.BlockPosSerializer
+import moe.nea.firmament.util.json.SingletonSerializableList
+
+
+object CustomBlockTextures {
+ @Serializable
+ data class CustomBlockOverride(
+ val modes: @Serializable(SingletonSerializableList::class) List<String>,
+ val area: List<Area>? = null,
+ val replacements: Map<Identifier, Replacement>,
+ )
+
+ @Serializable(with = Replacement.Serializer::class)
+ data class Replacement(
+ val block: Identifier,
+ val sound: Identifier?,
+ ) {
+
+ @Transient
+ val blockModelIdentifier get() = ModelIdentifier(block.withPrefixedPath("block/"), "firmament")
+
+ @Transient
+ val bakedModel: BakedModel by lazy(LazyThreadSafetyMode.NONE) {
+ MC.instance.bakedModelManager.getModel(blockModelIdentifier)
+ }
+
+ @OptIn(ExperimentalSerializationApi::class)
+ @kotlinx.serialization.Serializer(Replacement::class)
+ object DefaultSerializer : KSerializer<Replacement>
+
+ object Serializer : KSerializer<Replacement> {
+ val delegate = serializer<JsonElement>()
+ override val descriptor: SerialDescriptor
+ get() = delegate.descriptor
+
+ override fun deserialize(decoder: Decoder): Replacement {
+ val jsonElement = decoder.decodeSerializableValue(delegate)
+ if (jsonElement is JsonPrimitive) {
+ require(jsonElement.isString)
+ return Replacement(Identifier.tryParse(jsonElement.content)!!, null)
+ }
+ return (decoder as JsonDecoder).json.decodeFromJsonElement(DefaultSerializer, jsonElement)
+ }
+
+ override fun serialize(encoder: Encoder, value: Replacement) {
+ encoder.encodeSerializableValue(DefaultSerializer, value)
+ }
+ }
+ }
+
+ @Serializable
+ data class Area(
+ val min: BlockPos,
+ val max: BlockPos,
+ ) {
+ @Transient
+ val realMin = BlockPos(
+ minOf(min.x, max.x),
+ minOf(min.y, max.y),
+ minOf(min.z, max.z),
+ )
+
+ @Transient
+ val realMax = BlockPos(
+ maxOf(min.x, max.x),
+ maxOf(min.y, max.y),
+ maxOf(min.z, max.z),
+ )
+
+ fun roughJoin(other: Area): Area {
+ return Area(
+ BlockPos(
+ minOf(realMin.x, other.realMin.x),
+ minOf(realMin.y, other.realMin.y),
+ minOf(realMin.z, other.realMin.z),
+ ),
+ BlockPos(
+ maxOf(realMax.x, other.realMax.x),
+ maxOf(realMax.y, other.realMax.y),
+ maxOf(realMax.z, other.realMax.z),
+ )
+ )
+ }
+
+ fun contains(blockPos: BlockPos): Boolean {
+ return (blockPos.x in realMin.x..realMax.x) &&
+ (blockPos.y in realMin.y..realMax.y) &&
+ (blockPos.z in realMin.z..realMax.z)
+ }
+ }
+
+ data class LocationReplacements(
+ val lookup: Map<Block, List<BlockReplacement>>
+ )
+
+ data class BlockReplacement(
+ val checks: List<Area>?,
+ val replacement: Replacement,
+ ) {
+ val roughCheck by lazy(LazyThreadSafetyMode.NONE) {
+ if (checks == null || checks.size < 3) return@lazy null
+ checks.reduce { acc, next -> acc.roughJoin(next) }
+ }
+ }
+
+ data class BakedReplacements(val data: Map<SkyBlockIsland, LocationReplacements>)
+
+ var allLocationReplacements: BakedReplacements = BakedReplacements(mapOf())
+ var currentIslandReplacements: LocationReplacements? = null
+
+ fun refreshReplacements() {
+ val location = SBData.skyblockLocation
+ val replacements =
+ if (CustomSkyBlockTextures.TConfig.enableBlockOverrides) location?.let(allLocationReplacements.data::get)
+ else null
+ val lastReplacements = currentIslandReplacements
+ currentIslandReplacements = replacements
+ if (lastReplacements != replacements) {
+ MC.nextTick {
+ MC.worldRenderer.chunks?.chunks?.forEach {
+ // false schedules rebuilds outside a 27 block radius to happen async
+ it.scheduleRebuild(false)
+ }
+ sodiumReloadTask?.run()
+ }
+ }
+ }
+
+ private val sodiumReloadTask = runCatching {
+ Class.forName("moe.nea.firmament.compat.sodium.SodiumChunkReloader").getConstructor().newInstance() as Runnable
+ }.getOrElse {
+ if (FabricLoader.getInstance().isModLoaded("sodium"))
+ logger.error("Could not create sodium chunk reloader")
+ null
+ }
+
+
+ fun matchesPosition(replacement: BlockReplacement, blockPos: BlockPos?): Boolean {
+ if (blockPos == null) return true
+ val rc = replacement.roughCheck
+ if (rc != null && !rc.contains(blockPos)) return false
+ val areas = replacement.checks
+ if (areas != null && !areas.any { it.contains(blockPos) }) return false
+ return true
+ }
+
+ @JvmStatic
+ fun getReplacementModel(block: BlockState, blockPos: BlockPos?): BakedModel? {
+ return getReplacement(block, blockPos)?.bakedModel
+ }
+
+ @JvmStatic
+ fun getReplacement(block: BlockState, blockPos: BlockPos?): Replacement? {
+ if (isInFallback() && blockPos == null) return null
+ val replacements = currentIslandReplacements?.lookup?.get(block.block) ?: return null
+ for (replacement in replacements) {
+ if (replacement.checks == null || matchesPosition(replacement, blockPos))
+ return replacement.replacement
+ }
+ return null
+ }
+
+
+ @Subscribe
+ fun onLocation(event: SkyblockServerUpdateEvent) {
+ refreshReplacements()
+ }
+
+ @Volatile
+ var preparationFuture: CompletableFuture<BakedReplacements> = CompletableFuture.completedFuture(BakedReplacements(
+ mapOf()))
+
+ val insideFallbackCall = ThreadLocal.withInitial { 0 }
+
+ @JvmStatic
+ fun enterFallbackCall() {
+ insideFallbackCall.set(insideFallbackCall.get() + 1)
+ }
+
+ fun isInFallback() = insideFallbackCall.get() > 0
+
+ @JvmStatic
+ fun exitFallbackCall() {
+ insideFallbackCall.set(insideFallbackCall.get() - 1)
+ }
+
+ @Subscribe
+ fun onEarlyReload(event: EarlyResourceReloadEvent) {
+ preparationFuture = CompletableFuture
+ .supplyAsync(
+ { prepare(event.resourceManager) }, event.preparationExecutor)
+ }
+
+ @Subscribe
+ fun bakeExtraModels(event: BakeExtraModelsEvent) {
+ preparationFuture.join().data.values
+ .flatMap { it.lookup.values }
+ .flatten()
+ .mapTo(mutableSetOf()) { it.replacement.blockModelIdentifier }
+ .forEach { event.addNonItemModel(it) }
+ }
+
+ private fun prepare(manager: ResourceManager): BakedReplacements {
+ val resources = manager.findResources("overrides/blocks") {
+ it.namespace == "firmskyblock" && it.path.endsWith(".json")
+ }
+ val map = mutableMapOf<SkyBlockIsland, MutableMap<Block, MutableList<BlockReplacement>>>()
+ for ((file, resource) in resources) {
+ val json =
+ Firmament.tryDecodeJsonFromStream<CustomBlockOverride>(resource.inputStream)
+ .getOrElse { ex ->
+ logger.error("Failed to load block texture override at $file", ex)
+ continue
+ }
+ for (mode in json.modes) {
+ val island = SkyBlockIsland.forMode(mode)
+ val islandMpa = map.getOrPut(island, ::mutableMapOf)
+ for ((blockId, replacement) in json.replacements) {
+ val block = MC.defaultRegistries.getWrapperOrThrow(RegistryKeys.BLOCK)
+ .getOptional(RegistryKey.of(RegistryKeys.BLOCK, blockId))
+ .getOrNull()
+ if (block == null) {
+ logger.error("Failed to load block texture override at ${file}: unknown block '$blockId'")
+ continue
+ }
+ val replacements = islandMpa.getOrPut(block.value(), ::mutableListOf)
+ replacements.add(BlockReplacement(json.area, replacement))
+ }
+ }
+ }
+
+ return BakedReplacements(map.mapValues { LocationReplacements(it.value) })
+ }
+
+ @JvmStatic
+ fun patchIndigo(orig: BakedModel, pos: BlockPos, state: BlockState): BakedModel {
+ return getReplacementModel(state, pos) ?: orig
+ }
+
+ @Subscribe
+ fun onStart(event: FinalizeResourceManagerEvent) {
+ event.resourceManager.registerReloader(object :
+ SinglePreparationResourceReloader<BakedReplacements>() {
+ override fun prepare(manager: ResourceManager, profiler: Profiler): BakedReplacements {
+ return preparationFuture.join()
+ }
+
+ override fun apply(prepared: BakedReplacements, manager: ResourceManager, profiler: Profiler?) {
+ allLocationReplacements = prepared
+ refreshReplacements()
+ }
+ })
+ }
+}
diff --git a/src/main/kotlin/features/texturepack/CustomGlobalArmorOverrides.kt b/src/main/kotlin/features/texturepack/CustomGlobalArmorOverrides.kt
new file mode 100644
index 0000000..23577ee
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/CustomGlobalArmorOverrides.kt
@@ -0,0 +1,106 @@
+
+@file:UseSerializers(IdentifierSerializer::class)
+
+package moe.nea.firmament.features.texturepack
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.Transient
+import kotlinx.serialization.UseSerializers
+import net.minecraft.item.ArmorMaterial
+import net.minecraft.item.ItemStack
+import net.minecraft.resource.ResourceManager
+import net.minecraft.resource.SinglePreparationResourceReloader
+import net.minecraft.util.Identifier
+import net.minecraft.util.profiler.Profiler
+import moe.nea.firmament.Firmament
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.FinalizeResourceManagerEvent
+import moe.nea.firmament.events.subscription.SubscriptionOwner
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.features.texturepack.CustomGlobalTextures.logger
+import moe.nea.firmament.util.IdentifierSerializer
+import moe.nea.firmament.util.IdentityCharacteristics
+import moe.nea.firmament.util.computeNullableFunction
+import moe.nea.firmament.util.skyBlockId
+
+object CustomGlobalArmorOverrides : SubscriptionOwner {
+ @Serializable
+ data class ArmorOverride(
+ @SerialName("item_ids")
+ val itemIds: List<String>,
+ val layers: List<ArmorOverrideLayer>,
+ val overrides: List<ArmorOverrideOverride> = listOf(),
+ ) {
+ @Transient
+ val bakedLayers = bakeLayers(layers)
+ }
+
+ fun bakeLayers(layers: List<ArmorOverrideLayer>): List<ArmorMaterial.Layer> {
+ return layers.map { ArmorMaterial.Layer(it.identifier, it.suffix, it.tint) }
+ }
+
+ @Serializable
+ data class ArmorOverrideLayer(
+ val tint: Boolean = false,
+ val identifier: Identifier,
+ val suffix: String = "",
+ )
+
+ @Serializable
+ data class ArmorOverrideOverride(
+ val predicate: FirmamentModelPredicate,
+ val layers: List<ArmorOverrideLayer>,
+ ) {
+ @Transient
+ val bakedLayers = bakeLayers(layers)
+ }
+
+ override val delegateFeature: FirmamentFeature
+ get() = CustomSkyBlockTextures
+
+ val overrideCache = mutableMapOf<IdentityCharacteristics<ItemStack>, Any>()
+
+ @JvmStatic
+ fun overrideArmor(stack: ItemStack): List<ArmorMaterial.Layer>? {
+ if (!CustomSkyBlockTextures.TConfig.enableArmorOverrides) return null
+ return overrideCache.computeNullableFunction(IdentityCharacteristics(stack)) {
+ val id = stack.skyBlockId ?: return@computeNullableFunction null
+ val override = overrides[id.neuItem] ?: return@computeNullableFunction null
+ for (suboverride in override.overrides) {
+ if (suboverride.predicate.test(stack)) {
+ return@computeNullableFunction suboverride.bakedLayers
+ }
+ }
+ return@computeNullableFunction override.bakedLayers
+ }
+ }
+
+ var overrides: Map<String, ArmorOverride> = mapOf()
+
+ @Subscribe
+ fun onStart(event: FinalizeResourceManagerEvent) {
+ event.resourceManager.registerReloader(object :
+ SinglePreparationResourceReloader<Map<String, ArmorOverride>>() {
+ override fun prepare(manager: ResourceManager, profiler: Profiler): Map<String, ArmorOverride> {
+ val overrideFiles = manager.findResources("overrides/armor_models") {
+ it.namespace == "firmskyblock" && it.path.endsWith(".json")
+ }
+ val overrides = overrideFiles.mapNotNull {
+ Firmament.tryDecodeJsonFromStream<ArmorOverride>(it.value.inputStream).getOrElse { ex ->
+ logger.error("Failed to load armor texture override at ${it.key}", ex)
+ null
+ }
+ }
+ val associatedMap = overrides.flatMap { obj -> obj.itemIds.map { it to obj } }
+ .toMap()
+ return associatedMap
+ }
+
+ override fun apply(prepared: Map<String, ArmorOverride>, manager: ResourceManager, profiler: Profiler) {
+ overrides = prepared
+ }
+ })
+ }
+
+}
diff --git a/src/main/kotlin/features/texturepack/CustomGlobalTextures.kt b/src/main/kotlin/features/texturepack/CustomGlobalTextures.kt
new file mode 100644
index 0000000..d64c844
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/CustomGlobalTextures.kt
@@ -0,0 +1,167 @@
+
+@file:UseSerializers(IdentifierSerializer::class, CustomModelOverrideParser.FirmamentRootPredicateSerializer::class)
+
+package moe.nea.firmament.features.texturepack
+
+
+import java.util.concurrent.CompletableFuture
+import org.slf4j.LoggerFactory
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.UseSerializers
+import kotlin.jvm.optionals.getOrNull
+import net.minecraft.client.render.item.ItemModels
+import net.minecraft.client.render.model.BakedModel
+import net.minecraft.client.util.ModelIdentifier
+import net.minecraft.item.ItemStack
+import net.minecraft.resource.ResourceManager
+import net.minecraft.resource.SinglePreparationResourceReloader
+import net.minecraft.text.Text
+import net.minecraft.util.Identifier
+import net.minecraft.util.profiler.Profiler
+import moe.nea.firmament.Firmament
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.BakeExtraModelsEvent
+import moe.nea.firmament.events.EarlyResourceReloadEvent
+import moe.nea.firmament.events.FinalizeResourceManagerEvent
+import moe.nea.firmament.events.ScreenChangeEvent
+import moe.nea.firmament.events.subscription.SubscriptionOwner
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.util.IdentifierSerializer
+import moe.nea.firmament.util.IdentityCharacteristics
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.computeNullableFunction
+import moe.nea.firmament.util.json.SingletonSerializableList
+import moe.nea.firmament.util.runNull
+
+object CustomGlobalTextures : SinglePreparationResourceReloader<CustomGlobalTextures.CustomGuiTextureOverride>(),
+ SubscriptionOwner {
+ override val delegateFeature: FirmamentFeature
+ get() = CustomSkyBlockTextures
+
+ class CustomGuiTextureOverride(
+ val classes: List<ItemOverrideCollection>
+ )
+
+ @Serializable
+ data class GlobalItemOverride(
+ val screen: @Serializable(SingletonSerializableList::class) List<Identifier>,
+ val model: Identifier,
+ val predicate: FirmamentModelPredicate,
+ )
+
+ @Serializable
+ data class ScreenFilter(
+ val title: StringMatcher,
+ )
+
+ data class ItemOverrideCollection(
+ val screenFilter: ScreenFilter,
+ val overrides: List<GlobalItemOverride>,
+ )
+
+ @Subscribe
+ fun onStart(event: FinalizeResourceManagerEvent) {
+ MC.resourceManager.registerReloader(this)
+ }
+
+ @Subscribe
+ fun onEarlyReload(event: EarlyResourceReloadEvent) {
+ preparationFuture = CompletableFuture
+ .supplyAsync(
+ {
+ prepare(event.resourceManager)
+ }, event.preparationExecutor)
+ }
+
+ @Subscribe
+ fun onBakeModels(event: BakeExtraModelsEvent) {
+ for (guiClassOverride in preparationFuture.join().classes) {
+ for (override in guiClassOverride.overrides) {
+ event.addItemModel(ModelIdentifier(override.model, "inventory"))
+ }
+ }
+ }
+
+ @Volatile
+ var preparationFuture: CompletableFuture<CustomGuiTextureOverride> = CompletableFuture.completedFuture(
+ CustomGuiTextureOverride(listOf()))
+
+ override fun prepare(manager: ResourceManager?, profiler: Profiler?): CustomGuiTextureOverride {
+ return preparationFuture.join()
+ }
+
+ override fun apply(prepared: CustomGuiTextureOverride, manager: ResourceManager?, profiler: Profiler?) {
+ this.guiClassOverrides = prepared
+ }
+
+ val logger = LoggerFactory.getLogger(CustomGlobalTextures::class.java)
+ fun prepare(manager: ResourceManager): CustomGuiTextureOverride {
+ val overrideResources =
+ manager.findResources("overrides/item") { it.namespace == "firmskyblock" && it.path.endsWith(".json") }
+ .mapNotNull {
+ Firmament.tryDecodeJsonFromStream<GlobalItemOverride>(it.value.inputStream).getOrElse { ex ->
+ logger.error("Failed to load global item override at ${it.key}", ex)
+ null
+ }
+ }
+
+ val byGuiClass = overrideResources.flatMap { override -> override.screen.toSet().map { it to override } }
+ .groupBy { it.first }
+ val guiClasses = byGuiClass.entries
+ .mapNotNull {
+ val key = it.key
+ val guiClassResource =
+ manager.getResource(Identifier.of(key.namespace, "filters/screen/${key.path}.json"))
+ .getOrNull()
+ ?: return@mapNotNull runNull {
+ logger.error("Failed to locate screen filter at $key")
+ }
+ val screenFilter =
+ Firmament.tryDecodeJsonFromStream<ScreenFilter>(guiClassResource.inputStream)
+ .getOrElse { ex ->
+ logger.error("Failed to load screen filter at $key", ex)
+ return@mapNotNull null
+ }
+ ItemOverrideCollection(screenFilter, it.value.map { it.second })
+ }
+ logger.info("Loaded ${overrideResources.size} global item overrides")
+ return CustomGuiTextureOverride(guiClasses)
+ }
+
+ var guiClassOverrides = CustomGuiTextureOverride(listOf())
+
+ var matchingOverrides: Set<ItemOverrideCollection> = setOf()
+
+ @Subscribe
+ fun onOpenGui(event: ScreenChangeEvent) {
+ val newTitle = event.new?.title ?: Text.empty()
+ matchingOverrides = guiClassOverrides.classes
+ .filterTo(mutableSetOf()) { it.screenFilter.title.matches(newTitle) }
+ }
+
+ val overrideCache = mutableMapOf<IdentityCharacteristics<ItemStack>, Any>()
+
+ @JvmStatic
+ fun replaceGlobalModel(
+ models: ItemModels,
+ stack: ItemStack,
+ cir: CallbackInfoReturnable<BakedModel>
+ ) {
+ val value = overrideCache.computeNullableFunction(IdentityCharacteristics(stack)) {
+ for (guiClassOverride in matchingOverrides) {
+ for (override in guiClassOverride.overrides) {
+ if (override.predicate.test(stack)) {
+ return@computeNullableFunction models.modelManager.getModel(
+ ModelIdentifier(override.model, "inventory"))
+ }
+ }
+ }
+ null
+ }
+ if (value != null)
+ cir.returnValue = value
+ }
+
+
+}
diff --git a/src/main/kotlin/features/texturepack/CustomModelOverrideParser.kt b/src/main/kotlin/features/texturepack/CustomModelOverrideParser.kt
new file mode 100644
index 0000000..a4e7c02
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/CustomModelOverrideParser.kt
@@ -0,0 +1,74 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonObject
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import net.minecraft.item.ItemStack
+import net.minecraft.util.Identifier
+
+object CustomModelOverrideParser {
+ object FirmamentRootPredicateSerializer : KSerializer<FirmamentModelPredicate> {
+ val delegateSerializer = kotlinx.serialization.json.JsonObject.serializer()
+ override val descriptor: SerialDescriptor
+ get() = SerialDescriptor("FirmamentModelRootPredicate", delegateSerializer.descriptor)
+
+ override fun deserialize(decoder: Decoder): FirmamentModelPredicate {
+ val json = decoder.decodeSerializableValue(delegateSerializer).intoGson() as JsonObject
+ return AndPredicate(parsePredicates(json).toTypedArray())
+ }
+
+ override fun serialize(encoder: Encoder, value: FirmamentModelPredicate) {
+ TODO("Cannot serialize firmament predicates")
+ }
+ }
+
+ val predicateParsers = mutableMapOf<Identifier, FirmamentModelPredicateParser>()
+
+
+ fun registerPredicateParser(name: String, parser: FirmamentModelPredicateParser) {
+ predicateParsers[Identifier.of("firmament", name)] = parser
+ }
+
+ init {
+ registerPredicateParser("display_name", DisplayNamePredicate.Parser)
+ registerPredicateParser("lore", LorePredicate.Parser)
+ registerPredicateParser("all", AndPredicate.Parser)
+ registerPredicateParser("any", OrPredicate.Parser)
+ registerPredicateParser("not", NotPredicate.Parser)
+ registerPredicateParser("item", ItemPredicate.Parser)
+ registerPredicateParser("extra_attributes", ExtraAttributesPredicate.Parser)
+ registerPredicateParser("pet", PetPredicate.Parser)
+ }
+
+ private val neverPredicate = listOf(
+ object : FirmamentModelPredicate {
+ override fun test(stack: ItemStack): Boolean {
+ return false
+ }
+ }
+ )
+
+ fun parsePredicates(predicates: JsonObject): List<FirmamentModelPredicate> {
+ val parsedPredicates = mutableListOf<FirmamentModelPredicate>()
+ for (predicateName in predicates.keySet()) {
+ if (!predicateName.startsWith("firmament:")) continue
+ val identifier = Identifier.of(predicateName)
+ val parser = predicateParsers[identifier] ?: return neverPredicate
+ val parsedPredicate = parser.parse(predicates[predicateName]) ?: return neverPredicate
+ parsedPredicates.add(parsedPredicate)
+ }
+ return parsedPredicates
+ }
+
+ @JvmStatic
+ fun parseCustomModelOverrides(jsonObject: JsonObject): Array<FirmamentModelPredicate>? {
+ val predicates = (jsonObject["predicate"] as? JsonObject) ?: return null
+ val parsedPredicates = parsePredicates(predicates)
+ if (parsedPredicates.isEmpty())
+ return null
+ return parsedPredicates.toTypedArray()
+ }
+}
diff --git a/src/main/kotlin/features/texturepack/CustomSkyBlockTextures.kt b/src/main/kotlin/features/texturepack/CustomSkyBlockTextures.kt
new file mode 100644
index 0000000..dec6046
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/CustomSkyBlockTextures.kt
@@ -0,0 +1,114 @@
+package moe.nea.firmament.features.texturepack
+
+import com.mojang.authlib.minecraft.MinecraftProfileTexture
+import com.mojang.authlib.properties.Property
+import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable
+import net.minecraft.block.SkullBlock
+import net.minecraft.client.MinecraftClient
+import net.minecraft.client.render.RenderLayer
+import net.minecraft.client.util.ModelIdentifier
+import net.minecraft.component.type.ProfileComponent
+import net.minecraft.util.Identifier
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.BakeExtraModelsEvent
+import moe.nea.firmament.events.CustomItemModelEvent
+import moe.nea.firmament.events.TickEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.IdentityCharacteristics
+import moe.nea.firmament.util.item.decodeProfileTextureProperty
+import moe.nea.firmament.util.skyBlockId
+
+object CustomSkyBlockTextures : FirmamentFeature {
+ override val identifier: String
+ get() = "custom-skyblock-textures"
+
+ object TConfig : ManagedConfig(identifier) {
+ val enabled by toggle("enabled") { true }
+ val skullsEnabled by toggle("skulls-enabled") { true }
+ val cacheDuration by integer("cache-duration", 0, 20) { 1 }
+ val enableModelOverrides by toggle("model-overrides") { true }
+ val enableArmorOverrides by toggle("armor-overrides") { true }
+ val enableBlockOverrides by toggle("block-overrides") { true }
+ }
+
+ override val config: ManagedConfig
+ get() = TConfig
+
+ @Subscribe
+ fun onTick(it: TickEvent) {
+ if (TConfig.cacheDuration < 1 || it.tickCount % TConfig.cacheDuration == 0) {
+ // TODO: unify all of those caches somehow
+ CustomItemModelEvent.clearCache()
+ skullTextureCache.clear()
+ CustomGlobalTextures.overrideCache.clear()
+ CustomGlobalArmorOverrides.overrideCache.clear()
+ }
+ }
+
+ @Subscribe
+ fun bakeCustomFirmModels(event: BakeExtraModelsEvent) {
+ val resources =
+ MinecraftClient.getInstance().resourceManager.findResources("models/item"
+ ) { it: Identifier ->
+ "firmskyblock" == it.namespace && it.path
+ .endsWith(".json")
+ }
+ for (identifier in resources.keys) {
+ val modelId = ModelIdentifier.ofInventoryVariant(
+ Identifier.of(
+ "firmskyblock",
+ identifier.path.substring(
+ "models/item/".length,
+ identifier.path.length - ".json".length),
+ ))
+ event.addItemModel(modelId)
+ }
+ }
+
+ @Subscribe
+ fun onCustomModelId(it: CustomItemModelEvent) {
+ if (!TConfig.enabled) return
+ val id = it.itemStack.skyBlockId ?: return
+ it.overrideModel = ModelIdentifier.ofInventoryVariant(Identifier.of("firmskyblock", id.identifier.path))
+ }
+
+ private val skullTextureCache = mutableMapOf<IdentityCharacteristics<ProfileComponent>, Any>()
+ private val sentinelPresentInvalid = Object()
+
+ private val mcUrlRegex = "https?://textures.minecraft.net/texture/([a-fA-F0-9]+)".toRegex()
+
+ fun getSkullId(textureProperty: Property): String? {
+ val texture = decodeProfileTextureProperty(textureProperty) ?: return null
+ val textureUrl =
+ texture.textures[MinecraftProfileTexture.Type.SKIN]?.url ?: return null
+ val mcUrlData = mcUrlRegex.matchEntire(textureUrl) ?: return null
+ return mcUrlData.groupValues[1]
+ }
+
+ fun getSkullTexture(profile: ProfileComponent): Identifier? {
+ val id = getSkullId(profile.properties["textures"].firstOrNull() ?: return null) ?: return null
+ return Identifier.of("firmskyblock", "textures/placedskull/$id.png")
+ }
+
+ fun modifySkullTexture(
+ type: SkullBlock.SkullType?,
+ component: ProfileComponent?,
+ cir: CallbackInfoReturnable<RenderLayer>
+ ) {
+ if (type != SkullBlock.Type.PLAYER) return
+ if (!TConfig.skullsEnabled) return
+ if (component == null) return
+ val ic = IdentityCharacteristics(component)
+
+ val n = skullTextureCache.getOrPut(ic) {
+ val id = getSkullTexture(component) ?: return@getOrPut sentinelPresentInvalid
+ if (!MinecraftClient.getInstance().resourceManager.getResource(id).isPresent) {
+ return@getOrPut sentinelPresentInvalid
+ }
+ return@getOrPut id
+ }
+ if (n === sentinelPresentInvalid) return
+ cir.returnValue = RenderLayer.getEntityTranslucent(n as Identifier)
+ }
+}
diff --git a/src/main/kotlin/features/texturepack/DisplayNamePredicate.kt b/src/main/kotlin/features/texturepack/DisplayNamePredicate.kt
new file mode 100644
index 0000000..c89931e
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/DisplayNamePredicate.kt
@@ -0,0 +1,22 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonElement
+import net.minecraft.item.ItemStack
+import net.minecraft.nbt.NbtElement
+import net.minecraft.nbt.NbtString
+import moe.nea.firmament.util.item.displayNameAccordingToNbt
+import moe.nea.firmament.util.item.loreAccordingToNbt
+
+data class DisplayNamePredicate(val stringMatcher: StringMatcher) : FirmamentModelPredicate {
+ override fun test(stack: ItemStack): Boolean {
+ val display = stack.displayNameAccordingToNbt
+ return stringMatcher.matches(display)
+ }
+
+ object Parser : FirmamentModelPredicateParser {
+ override fun parse(jsonElement: JsonElement): FirmamentModelPredicate {
+ return DisplayNamePredicate(StringMatcher.parse(jsonElement))
+ }
+ }
+}
diff --git a/src/main/kotlin/features/texturepack/ExtraAttributesPredicate.kt b/src/main/kotlin/features/texturepack/ExtraAttributesPredicate.kt
new file mode 100644
index 0000000..4114f45
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/ExtraAttributesPredicate.kt
@@ -0,0 +1,268 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonArray
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import com.google.gson.JsonPrimitive
+import net.minecraft.item.ItemStack
+import net.minecraft.nbt.NbtByte
+import net.minecraft.nbt.NbtCompound
+import net.minecraft.nbt.NbtDouble
+import net.minecraft.nbt.NbtElement
+import net.minecraft.nbt.NbtFloat
+import net.minecraft.nbt.NbtInt
+import net.minecraft.nbt.NbtList
+import net.minecraft.nbt.NbtLong
+import net.minecraft.nbt.NbtShort
+import net.minecraft.nbt.NbtString
+import moe.nea.firmament.util.extraAttributes
+
+fun interface NbtMatcher {
+ fun matches(nbt: NbtElement): Boolean
+
+ object Parser {
+ fun parse(jsonElement: JsonElement): NbtMatcher? {
+ if (jsonElement is JsonPrimitive) {
+ if (jsonElement.isString) {
+ val string = jsonElement.asString
+ return MatchStringExact(string)
+ }
+ if (jsonElement.isNumber) {
+ return MatchNumberExact(jsonElement.asLong) //TODO: parse generic number
+ }
+ }
+ if (jsonElement is JsonObject) {
+ var encounteredParser: NbtMatcher? = null
+ for (entry in ExclusiveParserType.entries) {
+ val data = jsonElement[entry.key] ?: continue
+ if (encounteredParser != null) {
+ // TODO: warn
+ return null
+ }
+ encounteredParser = entry.parse(data) ?: return null
+ }
+ return encounteredParser
+ }
+ return null
+ }
+
+ enum class ExclusiveParserType(val key: String) {
+ STRING("string") {
+ override fun parse(element: JsonElement): NbtMatcher? {
+ return MatchString(StringMatcher.parse(element))
+ }
+ },
+ INT("int") {
+ override fun parse(element: JsonElement): NbtMatcher? {
+ return parseGenericNumber(element,
+ { it.asInt },
+ { (it as? NbtInt)?.intValue() },
+ { a, b ->
+ if (a == b) Comparison.EQUAL
+ else if (a < b) Comparison.LESS_THAN
+ else Comparison.GREATER
+ })
+ }
+ },
+ FLOAT("float") {
+ override fun parse(element: JsonElement): NbtMatcher? {
+ return parseGenericNumber(element,
+ { it.asFloat },
+ { (it as? NbtFloat)?.floatValue() },
+ { a, b ->
+ if (a == b) Comparison.EQUAL
+ else if (a < b) Comparison.LESS_THAN
+ else Comparison.GREATER
+ })
+ }
+ },
+ DOUBLE("double") {
+ override fun parse(element: JsonElement): NbtMatcher? {
+ return parseGenericNumber(element,
+ { it.asDouble },
+ { (it as? NbtDouble)?.doubleValue() },
+ { a, b ->
+ if (a == b) Comparison.EQUAL
+ else if (a < b) Comparison.LESS_THAN
+ else Comparison.GREATER
+ })
+ }
+ },
+ LONG("long") {
+ override fun parse(element: JsonElement): NbtMatcher? {
+ return parseGenericNumber(element,
+ { it.asLong },
+ { (it as? NbtLong)?.longValue() },
+ { a, b ->
+ if (a == b) Comparison.EQUAL
+ else if (a < b) Comparison.LESS_THAN
+ else Comparison.GREATER
+ })
+ }
+ },
+ SHORT("short") {
+ override fun parse(element: JsonElement): NbtMatcher? {
+ return parseGenericNumber(element,
+ { it.asShort },
+ { (it as? NbtShort)?.shortValue() },
+ { a, b ->
+ if (a == b) Comparison.EQUAL
+ else if (a < b) Comparison.LESS_THAN
+ else Comparison.GREATER
+ })
+ }
+ },
+ BYTE("byte") {
+ override fun parse(element: JsonElement): NbtMatcher? {
+ return parseGenericNumber(element,
+ { it.asByte },
+ { (it as? NbtByte)?.byteValue() },
+ { a, b ->
+ if (a == b) Comparison.EQUAL
+ else if (a < b) Comparison.LESS_THAN
+ else Comparison.GREATER
+ })
+ }
+ },
+ ;
+
+ abstract fun parse(element: JsonElement): NbtMatcher?
+ }
+
+ enum class Comparison {
+ LESS_THAN, EQUAL, GREATER
+ }
+
+ inline fun <T : Any> parseGenericNumber(
+ jsonElement: JsonElement,
+ primitiveExtractor: (JsonPrimitive) -> T?,
+ crossinline nbtExtractor: (NbtElement) -> T?,
+ crossinline compare: (T, T) -> Comparison
+ ): NbtMatcher? {
+ if (jsonElement is JsonPrimitive) {
+ val expected = primitiveExtractor(jsonElement) ?: return null
+ return NbtMatcher {
+ val actual = nbtExtractor(it) ?: return@NbtMatcher false
+ compare(actual, expected) == Comparison.EQUAL
+ }
+ }
+ if (jsonElement is JsonObject) {
+ val minElement = jsonElement.getAsJsonPrimitive("min")
+ val min = if (minElement != null) primitiveExtractor(minElement) ?: return null else null
+ val minExclusive = jsonElement.get("minExclusive")?.asBoolean ?: false
+ val maxElement = jsonElement.getAsJsonPrimitive("max")
+ val max = if (maxElement != null) primitiveExtractor(maxElement) ?: return null else null
+ val maxExclusive = jsonElement.get("maxExclusive")?.asBoolean ?: true
+ if (min == null && max == null) return null
+ return NbtMatcher {
+ val actual = nbtExtractor(it) ?: return@NbtMatcher false
+ if (max != null) {
+ val comp = compare(actual, max)
+ if (comp == Comparison.GREATER) return@NbtMatcher false
+ if (comp == Comparison.EQUAL && maxExclusive) return@NbtMatcher false
+ }
+ if (min != null) {
+ val comp = compare(actual, min)
+ if (comp == Comparison.LESS_THAN) return@NbtMatcher false
+ if (comp == Comparison.EQUAL && minExclusive) return@NbtMatcher false
+ }
+ return@NbtMatcher true
+ }
+ }
+ return null
+
+ }
+ }
+
+ class MatchNumberExact(val number: Long) : NbtMatcher {
+ override fun matches(nbt: NbtElement): Boolean {
+ return when (nbt) {
+ is NbtByte -> nbt.byteValue().toLong() == number
+ is NbtInt -> nbt.intValue().toLong() == number
+ is NbtShort -> nbt.shortValue().toLong() == number
+ is NbtLong -> nbt.longValue().toLong() == number
+ else -> false
+ }
+ }
+
+ }
+
+ class MatchStringExact(val string: String) : NbtMatcher {
+ override fun matches(nbt: NbtElement): Boolean {
+ return nbt is NbtString && nbt.asString() == string
+ }
+
+ override fun toString(): String {
+ return "MatchNbtStringExactly($string)"
+ }
+ }
+
+ class MatchString(val string: StringMatcher) : NbtMatcher {
+ override fun matches(nbt: NbtElement): Boolean {
+ return nbt is NbtString && string.matches(nbt.asString())
+ }
+
+ override fun toString(): String {
+ return "MatchNbtString($string)"
+ }
+ }
+}
+
+data class ExtraAttributesPredicate(
+ val path: NbtPrism,
+ val matcher: NbtMatcher,
+) : FirmamentModelPredicate {
+
+ object Parser : FirmamentModelPredicateParser {
+ override fun parse(jsonElement: JsonElement): FirmamentModelPredicate? {
+ if (jsonElement !is JsonObject) return null
+ val path = jsonElement.get("path") ?: return null
+ val pathSegments = if (path is JsonArray) {
+ path.map { (it as JsonPrimitive).asString }
+ } else if (path is JsonPrimitive && path.isString) {
+ path.asString.split(".")
+ } else return null
+ val matcher = NbtMatcher.Parser.parse(jsonElement.get("match") ?: jsonElement)
+ ?: return null
+ return ExtraAttributesPredicate(NbtPrism(pathSegments), matcher)
+ }
+ }
+
+ override fun test(stack: ItemStack): Boolean {
+ return path.access(stack.extraAttributes)
+ .any { matcher.matches(it) }
+ }
+}
+
+class NbtPrism(val path: List<String>) {
+ override fun toString(): String {
+ return "Prism($path)"
+ }
+ fun access(root: NbtElement): Collection<NbtElement> {
+ var rootSet = mutableListOf(root)
+ var switch = mutableListOf<NbtElement>()
+ for (pathSegment in path) {
+ if (pathSegment == ".") continue
+ for (element in rootSet) {
+ if (element is NbtList) {
+ if (pathSegment == "*")
+ switch.addAll(element)
+ val index = pathSegment.toIntOrNull() ?: continue
+ if (index !in element.indices) continue
+ switch.add(element[index])
+ }
+ if (element is NbtCompound) {
+ if (pathSegment == "*")
+ element.keys.mapTo(switch) { element.get(it)!! }
+ switch.add(element.get(pathSegment) ?: continue)
+ }
+ }
+ val temp = switch
+ switch = rootSet
+ rootSet = temp
+ switch.clear()
+ }
+ return rootSet
+ }
+}
diff --git a/src/main/kotlin/features/texturepack/FirmamentModelPredicate.kt b/src/main/kotlin/features/texturepack/FirmamentModelPredicate.kt
new file mode 100644
index 0000000..d11fec0
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/FirmamentModelPredicate.kt
@@ -0,0 +1,8 @@
+
+package moe.nea.firmament.features.texturepack
+
+import net.minecraft.item.ItemStack
+
+interface FirmamentModelPredicate {
+ fun test(stack: ItemStack): Boolean
+}
diff --git a/src/main/kotlin/features/texturepack/FirmamentModelPredicateParser.kt b/src/main/kotlin/features/texturepack/FirmamentModelPredicateParser.kt
new file mode 100644
index 0000000..3ed0c67
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/FirmamentModelPredicateParser.kt
@@ -0,0 +1,8 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonElement
+
+interface FirmamentModelPredicateParser {
+ fun parse(jsonElement: JsonElement): FirmamentModelPredicate?
+}
diff --git a/src/main/kotlin/features/texturepack/ItemPredicate.kt b/src/main/kotlin/features/texturepack/ItemPredicate.kt
new file mode 100644
index 0000000..4302b53
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/ItemPredicate.kt
@@ -0,0 +1,32 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonPrimitive
+import kotlin.jvm.optionals.getOrNull
+import net.minecraft.item.Item
+import net.minecraft.item.ItemStack
+import net.minecraft.registry.RegistryKey
+import net.minecraft.registry.RegistryKeys
+import net.minecraft.util.Identifier
+import moe.nea.firmament.util.MC
+
+class ItemPredicate(
+ val item: Item
+) : FirmamentModelPredicate {
+ override fun test(stack: ItemStack): Boolean {
+ return stack.item == item
+ }
+
+ object Parser : FirmamentModelPredicateParser {
+ override fun parse(jsonElement: JsonElement): ItemPredicate? {
+ if (jsonElement is JsonPrimitive && jsonElement.isString) {
+ val itemKey = RegistryKey.of(RegistryKeys.ITEM,
+ Identifier.tryParse(jsonElement.asString)
+ ?: return null)
+ return ItemPredicate(MC.defaultItems.getOptional(itemKey).getOrNull()?.value() ?: return null)
+ }
+ return null
+ }
+ }
+}
diff --git a/src/main/kotlin/features/texturepack/JsonUnbakedModelFirmExtra.kt b/src/main/kotlin/features/texturepack/JsonUnbakedModelFirmExtra.kt
new file mode 100644
index 0000000..ab9e27d
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/JsonUnbakedModelFirmExtra.kt
@@ -0,0 +1,10 @@
+
+package moe.nea.firmament.features.texturepack
+
+import net.minecraft.util.Identifier
+
+interface JsonUnbakedModelFirmExtra {
+
+ fun setHeadModel_firmament(identifier: Identifier?)
+ fun getHeadModel_firmament(): Identifier?
+}
diff --git a/src/main/kotlin/features/texturepack/LorePredicate.kt b/src/main/kotlin/features/texturepack/LorePredicate.kt
new file mode 100644
index 0000000..13e3974
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/LorePredicate.kt
@@ -0,0 +1,19 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonElement
+import net.minecraft.item.ItemStack
+import moe.nea.firmament.util.item.loreAccordingToNbt
+
+class LorePredicate(val matcher: StringMatcher) : FirmamentModelPredicate {
+ object Parser : FirmamentModelPredicateParser {
+ override fun parse(jsonElement: JsonElement): FirmamentModelPredicate {
+ return LorePredicate(StringMatcher.parse(jsonElement))
+ }
+ }
+
+ override fun test(stack: ItemStack): Boolean {
+ val lore = stack.loreAccordingToNbt
+ return lore.any { matcher.matches(it) }
+ }
+}
diff --git a/src/main/kotlin/features/texturepack/ModelOverrideData.kt b/src/main/kotlin/features/texturepack/ModelOverrideData.kt
new file mode 100644
index 0000000..1585bd7
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/ModelOverrideData.kt
@@ -0,0 +1,7 @@
+
+package moe.nea.firmament.features.texturepack
+
+interface ModelOverrideData {
+ fun getFirmamentOverrides(): Array<FirmamentModelPredicate>?
+ fun setFirmamentOverrides(overrides: Array<FirmamentModelPredicate>?)
+}
diff --git a/src/main/kotlin/features/texturepack/ModelOverrideFilterSet.kt b/src/main/kotlin/features/texturepack/ModelOverrideFilterSet.kt
new file mode 100644
index 0000000..4ef8d06
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/ModelOverrideFilterSet.kt
@@ -0,0 +1,19 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonElement
+import moe.nea.firmament.util.filter.IteratorFilterSet
+
+class ModelOverrideFilterSet(original: java.util.Set<Map.Entry<String, JsonElement>>) :
+ IteratorFilterSet<Map.Entry<String, JsonElement>>(original) {
+ companion object {
+ @JvmStatic
+ fun createFilterSet(set: java.util.Set<*>): java.util.Set<*> {
+ return ModelOverrideFilterSet(set as java.util.Set<Map.Entry<String, JsonElement>>) as java.util.Set<*>
+ }
+ }
+
+ override fun shouldKeepElement(element: Map.Entry<String, JsonElement>): Boolean {
+ return !element.key.startsWith("firmament:")
+ }
+}
diff --git a/src/main/kotlin/features/texturepack/NotPredicate.kt b/src/main/kotlin/features/texturepack/NotPredicate.kt
new file mode 100644
index 0000000..ecd67c3
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/NotPredicate.kt
@@ -0,0 +1,18 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import net.minecraft.item.ItemStack
+
+class NotPredicate(val children: Array<FirmamentModelPredicate>) : FirmamentModelPredicate {
+ override fun test(stack: ItemStack): Boolean {
+ return children.none { it.test(stack) }
+ }
+
+ object Parser : FirmamentModelPredicateParser {
+ override fun parse(jsonElement: JsonElement): FirmamentModelPredicate {
+ return NotPredicate(CustomModelOverrideParser.parsePredicates(jsonElement as JsonObject).toTypedArray())
+ }
+ }
+}
diff --git a/src/main/kotlin/features/texturepack/NumberMatcher.kt b/src/main/kotlin/features/texturepack/NumberMatcher.kt
new file mode 100644
index 0000000..7e6665f
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/NumberMatcher.kt
@@ -0,0 +1,125 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonPrimitive
+import moe.nea.firmament.util.useMatch
+
+abstract class NumberMatcher {
+ abstract fun test(number: Number): Boolean
+
+
+ companion object {
+ fun parse(jsonElement: JsonElement): NumberMatcher? {
+ if (jsonElement is JsonPrimitive) {
+ if (jsonElement.isString) {
+ val string = jsonElement.asString
+ return parseRange(string) ?: parseOperator(string)
+ }
+ if (jsonElement.isNumber) {
+ val number = jsonElement.asNumber
+ val hasDecimals = (number.toString().contains("."))
+ return MatchNumberExact(if (hasDecimals) number.toLong() else number.toDouble())
+ }
+ }
+ return null
+ }
+
+ private val intervalSpec =
+ "(?<beginningOpen>[\\[\\(])(?<beginning>[0-9.]+)?,(?<ending>[0-9.]+)?(?<endingOpen>[\\]\\)])"
+ .toPattern()
+
+ fun parseRange(string: String): RangeMatcher? {
+ intervalSpec.useMatch<Nothing>(string) {
+ // Open in the set-theory sense, meaning does not include its end.
+ val beginningOpen = group("beginningOpen") == "("
+ val endingOpen = group("endingOpen") == ")"
+ val beginning = group("beginning")?.toDouble()
+ val ending = group("ending")?.toDouble()
+ return RangeMatcher(beginning, !beginningOpen, ending, !endingOpen)
+ }
+ return null
+ }
+
+ enum class Operator(val operator: String) {
+ LESS("<") {
+ override fun matches(comparisonResult: Int): Boolean {
+ return comparisonResult < 0
+ }
+ },
+ LESS_EQUALS("<=") {
+ override fun matches(comparisonResult: Int): Boolean {
+ return comparisonResult <= 0
+ }
+ },
+ GREATER(">") {
+ override fun matches(comparisonResult: Int): Boolean {
+ return comparisonResult > 0
+ }
+ },
+ GREATER_EQUALS(">=") {
+ override fun matches(comparisonResult: Int): Boolean {
+ return comparisonResult >= 0
+ }
+ },
+ ;
+
+ abstract fun matches(comparisonResult: Int): Boolean
+ }
+
+ private val operatorPattern = "(?<operator>${Operator.entries.joinToString("|") {it.operator}})(?<value>[0-9.]+)".toPattern()
+
+ fun parseOperator(string: String): OperatorMatcher? {
+ operatorPattern.useMatch<Nothing>(string) {
+ val operatorName = group("operator")
+ val operator = Operator.entries.find { it.operator == operatorName }!!
+ val value = group("value").toDouble()
+ return OperatorMatcher(operator, value)
+ }
+ return null
+ }
+
+ data class OperatorMatcher(val operator: Operator, val value: Double) : NumberMatcher() {
+ override fun test(number: Number): Boolean {
+ return operator.matches(number.toDouble().compareTo(value))
+ }
+ }
+
+
+ data class MatchNumberExact(val number: Number) : NumberMatcher() {
+ override fun test(number: Number): Boolean {
+ return when (this.number) {
+ is Double -> number.toDouble() == this.number.toDouble()
+ else -> number.toLong() == this.number.toLong()
+ }
+ }
+ }
+
+ data class RangeMatcher(
+ val beginning: Double?,
+ val beginningInclusive: Boolean,
+ val ending: Double?,
+ val endingInclusive: Boolean,
+ ) : NumberMatcher() {
+ override fun test(number: Number): Boolean {
+ val value = number.toDouble()
+ if (beginning != null) {
+ if (beginningInclusive) {
+ if (value < beginning) return false
+ } else {
+ if (value <= beginning) return false
+ }
+ }
+ if (ending != null) {
+ if (endingInclusive) {
+ if (value > ending) return false
+ } else {
+ if (value >= ending) return false
+ }
+ }
+ return true
+ }
+ }
+ }
+
+}
diff --git a/src/main/kotlin/features/texturepack/OrPredicate.kt b/src/main/kotlin/features/texturepack/OrPredicate.kt
new file mode 100644
index 0000000..32f556b
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/OrPredicate.kt
@@ -0,0 +1,26 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonArray
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import net.minecraft.item.ItemStack
+
+class OrPredicate(val children: Array<FirmamentModelPredicate>) : FirmamentModelPredicate {
+ override fun test(stack: ItemStack): Boolean {
+ return children.any { it.test(stack) }
+ }
+
+ object Parser : FirmamentModelPredicateParser {
+ override fun parse(jsonElement: JsonElement): FirmamentModelPredicate {
+ val children =
+ (jsonElement as JsonArray)
+ .flatMap {
+ CustomModelOverrideParser.parsePredicates(it as JsonObject)
+ }
+ .toTypedArray()
+ return OrPredicate(children)
+ }
+
+ }
+}
diff --git a/src/main/kotlin/features/texturepack/PetPredicate.kt b/src/main/kotlin/features/texturepack/PetPredicate.kt
new file mode 100644
index 0000000..5e5d750
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/PetPredicate.kt
@@ -0,0 +1,66 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import net.minecraft.item.ItemStack
+import moe.nea.firmament.repo.ExpLadders
+import moe.nea.firmament.util.petData
+
+class PetPredicate(
+ val petId: StringMatcher?,
+ val tier: RarityMatcher?,
+ val exp: NumberMatcher?,
+ val candyUsed: NumberMatcher?,
+ val level: NumberMatcher?,
+) : FirmamentModelPredicate {
+
+ override fun test(stack: ItemStack): Boolean {
+ val petData = stack.petData ?: return false
+ if (petId != null) {
+ if (!petId.matches(petData.type)) return false
+ }
+ if (exp != null) {
+ if (!exp.test(petData.exp)) return false
+ }
+ if (candyUsed != null) {
+ if (!candyUsed.test(petData.candyUsed)) return false
+ }
+ if (tier != null) {
+ if (!tier.match(petData.tier)) return false
+ }
+ val levelData by lazy(LazyThreadSafetyMode.NONE) {
+ ExpLadders.getExpLadder(petData.type, petData.tier)
+ .getPetLevel(petData.exp)
+ }
+ if (level != null) {
+ if (!level.test(levelData.currentLevel)) return false
+ }
+ return true
+ }
+
+ object Parser : FirmamentModelPredicateParser {
+ override fun parse(jsonElement: JsonElement): FirmamentModelPredicate? {
+ if (jsonElement.isJsonPrimitive) {
+ return PetPredicate(StringMatcher.Equals(jsonElement.asString, false), null, null, null, null)
+ }
+ if (jsonElement !is JsonObject) return null
+ val idMatcher = jsonElement["id"]?.let(StringMatcher::parse)
+ val expMatcher = jsonElement["exp"]?.let(NumberMatcher::parse)
+ val levelMatcher = jsonElement["level"]?.let(NumberMatcher::parse)
+ val candyMatcher = jsonElement["candyUsed"]?.let(NumberMatcher::parse)
+ val tierMatcher = jsonElement["tier"]?.let(RarityMatcher::parse)
+ return PetPredicate(
+ idMatcher,
+ tierMatcher,
+ expMatcher,
+ candyMatcher,
+ levelMatcher,
+ )
+ }
+ }
+
+ override fun toString(): String {
+ return super.toString()
+ }
+}
diff --git a/src/main/kotlin/features/texturepack/RarityMatcher.kt b/src/main/kotlin/features/texturepack/RarityMatcher.kt
new file mode 100644
index 0000000..634a171
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/RarityMatcher.kt
@@ -0,0 +1,69 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonElement
+import io.github.moulberry.repo.data.Rarity
+import moe.nea.firmament.util.useMatch
+
+abstract class RarityMatcher {
+ abstract fun match(rarity: Rarity): Boolean
+
+ companion object {
+ fun parse(jsonElement: JsonElement): RarityMatcher {
+ val string = jsonElement.asString
+ val range = parseRange(string)
+ if (range != null) return range
+ return Exact(Rarity.valueOf(string))
+ }
+
+ private val allRarities = Rarity.entries.joinToString("|", "(?:", ")")
+ private val intervalSpec =
+ "(?<beginningOpen>[\\[\\(])(?<beginning>$allRarities)?,(?<ending>$allRarities)?(?<endingOpen>[\\]\\)])"
+ .toPattern()
+
+ fun parseRange(string: String): RangeMatcher? {
+ intervalSpec.useMatch<Nothing>(string) {
+ // Open in the set-theory sense, meaning does not include its end.
+ val beginningOpen = group("beginningOpen") == "("
+ val endingOpen = group("endingOpen") == ")"
+ val beginning = group("beginning")?.let(Rarity::valueOf)
+ val ending = group("ending")?.let(Rarity::valueOf)
+ return RangeMatcher(beginning, !beginningOpen, ending, !endingOpen)
+ }
+ return null
+ }
+
+ }
+
+ data class Exact(val expected: Rarity) : RarityMatcher() {
+ override fun match(rarity: Rarity): Boolean {
+ return rarity == expected
+ }
+ }
+
+ data class RangeMatcher(
+ val beginning: Rarity?,
+ val beginningInclusive: Boolean,
+ val ending: Rarity?,
+ val endingInclusive: Boolean,
+ ) : RarityMatcher() {
+ override fun match(rarity: Rarity): Boolean {
+ if (beginning != null) {
+ if (beginningInclusive) {
+ if (rarity < beginning) return false
+ } else {
+ if (rarity <= beginning) return false
+ }
+ }
+ if (ending != null) {
+ if (endingInclusive) {
+ if (rarity > ending) return false
+ } else {
+ if (rarity >= ending) return false
+ }
+ }
+ return true
+ }
+ }
+
+}
diff --git a/src/main/kotlin/features/texturepack/StringMatcher.kt b/src/main/kotlin/features/texturepack/StringMatcher.kt
new file mode 100644
index 0000000..5eb86ac
--- /dev/null
+++ b/src/main/kotlin/features/texturepack/StringMatcher.kt
@@ -0,0 +1,159 @@
+
+package moe.nea.firmament.features.texturepack
+
+import com.google.gson.JsonArray
+import com.google.gson.JsonElement
+import com.google.gson.JsonNull
+import com.google.gson.JsonObject
+import com.google.gson.JsonPrimitive
+import com.google.gson.internal.LazilyParsedNumber
+import java.util.function.Predicate
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import net.minecraft.nbt.NbtString
+import net.minecraft.text.Text
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.removeColorCodes
+
+@Serializable(with = StringMatcher.Serializer::class)
+interface StringMatcher {
+ fun matches(string: String): Boolean
+ fun matches(text: Text): Boolean {
+ return matches(text.string)
+ }
+
+ fun matches(nbt: NbtString): Boolean {
+ val string = nbt.asString()
+ val jsonStart = string.indexOf('{')
+ val stringStart = string.indexOf('"')
+ val isString = stringStart >= 0 && string.subSequence(0, stringStart).isBlank()
+ val isJson = jsonStart >= 0 && string.subSequence(0, jsonStart).isBlank()
+ if (isString || isJson)
+ return matches(Text.Serialization.fromJson(string, MC.defaultRegistries) ?: return false)
+ return matches(string)
+ }
+
+ class Equals(input: String, val stripColorCodes: Boolean) : StringMatcher {
+ private val expected = if (stripColorCodes) input.removeColorCodes() else input
+ override fun matches(string: String): Boolean {
+ return expected == (if (stripColorCodes) string.removeColorCodes() else string)
+ }
+
+ override fun toString(): String {
+ return "Equals($expected, stripColorCodes = $stripColorCodes)"
+ }
+ }
+
+ class Pattern(val patternWithColorCodes: String, val stripColorCodes: Boolean) : StringMatcher {
+ private val regex: Predicate<String> = patternWithColorCodes.toPattern().asMatchPredicate()
+ override fun matches(string: String): Boolean {
+ return regex.test(if (stripColorCodes) string.removeColorCodes() else string)
+ }
+
+ override fun toString(): String {
+ return "Pattern($patternWithColorCodes, stripColorCodes = $stripColorCodes)"
+ }
+ }
+
+ object Serializer : KSerializer<StringMatcher> {
+ val delegateSerializer = kotlinx.serialization.json.JsonElement.serializer()
+ override val descriptor: SerialDescriptor
+ get() = SerialDescriptor("StringMatcher", delegateSerializer.descriptor)
+
+ override fun deserialize(decoder: Decoder): StringMatcher {
+ val delegate = decoder.decodeSerializableValue(delegateSerializer)
+ val gsonDelegate = delegate.intoGson()
+ return parse(gsonDelegate)
+ }
+
+ override fun serialize(encoder: Encoder, value: StringMatcher) {
+ encoder.encodeSerializableValue(delegateSerializer, Companion.serialize(value).intoKotlinJson())
+ }
+
+ }
+
+ companion object {
+ fun serialize(stringMatcher: StringMatcher): JsonElement {
+ TODO("Cannot serialize string matchers rn")
+ }
+
+ fun parse(jsonElement: JsonElement): StringMatcher {
+ if (jsonElement is JsonPrimitive) {
+ return Equals(jsonElement.asString, true)
+ }
+ if (jsonElement is JsonObject) {
+ val regex = jsonElement["regex"] as JsonPrimitive?
+ val text = jsonElement["equals"] as JsonPrimitive?
+ val shouldStripColor = when (val color = (jsonElement["color"] as JsonPrimitive?)?.asString) {
+ "preserve" -> false
+ "strip", null -> true
+ else -> error("Unknown color preservation mode: $color")
+ }
+ if ((regex == null) == (text == null)) error("Could not parse $jsonElement as string matcher")
+ if (regex != null)
+ return Pattern(regex.asString, shouldStripColor)
+ if (text != null)
+ return Equals(text.asString, shouldStripColor)
+ }
+ error("Could not parse $jsonElement as a string matcher")
+ }
+ }
+}
+
+fun JsonElement.intoKotlinJson(): kotlinx.serialization.json.JsonElement {
+ when (this) {
+ is JsonNull -> return kotlinx.serialization.json.JsonNull
+ is JsonObject -> {
+ return kotlinx.serialization.json.JsonObject(this.entrySet()
+ .associate { it.key to it.value.intoKotlinJson() })
+ }
+
+ is JsonArray -> {
+ return kotlinx.serialization.json.JsonArray(this.map { it.intoKotlinJson() })
+ }
+
+ is JsonPrimitive -> {
+ if (this.isString)
+ return kotlinx.serialization.json.JsonPrimitive(this.asString)
+ if (this.isBoolean)
+ return kotlinx.serialization.json.JsonPrimitive(this.asBoolean)
+ return kotlinx.serialization.json.JsonPrimitive(this.asNumber)
+ }
+
+ else -> error("Unknown json variant $this")
+ }
+}
+
+fun kotlinx.serialization.json.JsonElement.intoGson(): JsonElement {
+ when (this) {
+ is kotlinx.serialization.json.JsonNull -> return JsonNull.INSTANCE
+ is kotlinx.serialization.json.JsonPrimitive -> {
+ if (this.isString)
+ return JsonPrimitive(this.content)
+ if (this.content == "true")
+ return JsonPrimitive(true)
+ if (this.content == "false")
+ return JsonPrimitive(false)
+ return JsonPrimitive(LazilyParsedNumber(this.content))
+ }
+
+ is kotlinx.serialization.json.JsonObject -> {
+ val obj = JsonObject()
+ for ((k, v) in this) {
+ obj.add(k, v.intoGson())
+ }
+ return obj
+ }
+
+ is kotlinx.serialization.json.JsonArray -> {
+ val arr = JsonArray()
+ for (v in this) {
+ arr.add(v.intoGson())
+ }
+ return arr
+ }
+ }
+}
diff --git a/src/main/kotlin/features/world/FairySouls.kt b/src/main/kotlin/features/world/FairySouls.kt
new file mode 100644
index 0000000..8a8291a
--- /dev/null
+++ b/src/main/kotlin/features/world/FairySouls.kt
@@ -0,0 +1,131 @@
+
+
+package moe.nea.firmament.features.world
+
+import io.github.moulberry.repo.data.Coordinate
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.serializer
+import net.minecraft.text.Text
+import net.minecraft.util.math.Vec3d
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.ProcessChatEvent
+import moe.nea.firmament.events.SkyblockServerUpdateEvent
+import moe.nea.firmament.events.WorldRenderLastEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.repo.RepoManager
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.SBData
+import moe.nea.firmament.util.SkyBlockIsland
+import moe.nea.firmament.util.blockPos
+import moe.nea.firmament.util.data.ProfileSpecificDataHolder
+import moe.nea.firmament.util.render.RenderInWorldContext
+import moe.nea.firmament.util.render.RenderInWorldContext.Companion.renderInWorld
+import moe.nea.firmament.util.unformattedString
+
+
+object FairySouls : FirmamentFeature {
+
+
+ @Serializable
+ data class Data(
+ val foundSouls: MutableMap<SkyBlockIsland, MutableSet<Int>> = mutableMapOf()
+ )
+
+ override val config: ManagedConfig
+ get() = TConfig
+
+ object DConfig : ProfileSpecificDataHolder<Data>(serializer(), "found-fairysouls", ::Data)
+
+
+ object TConfig : ManagedConfig("fairy-souls") {
+ val displaySouls by toggle("show") { false }
+ val resetSouls by button("reset") {
+ DConfig.data?.foundSouls?.clear() != null
+ updateMissingSouls()
+ }
+ }
+
+
+ override val identifier: String get() = "fairy-souls"
+
+ val playerReach = 5
+ val playerReachSquared = playerReach * playerReach
+
+ var currentLocationName: SkyBlockIsland? = null
+ var currentLocationSouls: List<Coordinate> = emptyList()
+ var currentMissingSouls: List<Coordinate> = emptyList()
+
+ fun updateMissingSouls() {
+ currentMissingSouls = emptyList()
+ val c = DConfig.data ?: return
+ val fi = c.foundSouls[currentLocationName] ?: setOf()
+ val cms = currentLocationSouls.toMutableList()
+ fi.asSequence().sortedDescending().filter { it in cms.indices }.forEach { cms.removeAt(it) }
+ currentMissingSouls = cms
+ }
+
+ fun updateWorldSouls() {
+ currentLocationSouls = emptyList()
+ val loc = currentLocationName ?: return
+ currentLocationSouls = RepoManager.neuRepo.constants.fairySouls.soulLocations[loc.locrawMode] ?: return
+ }
+
+ fun findNearestClickableSoul(): Coordinate? {
+ val player = MC.player ?: return null
+ val pos = player.pos
+ val location = SBData.skyblockLocation ?: return null
+ val soulLocations: List<Coordinate> =
+ RepoManager.neuRepo.constants.fairySouls.soulLocations[location.locrawMode] ?: return null
+ return soulLocations
+ .map { it to it.blockPos.getSquaredDistance(pos) }
+ .filter { it.second < playerReachSquared }
+ .minByOrNull { it.second }
+ ?.first
+ }
+
+ private fun markNearestSoul() {
+ val nearestSoul = findNearestClickableSoul() ?: return
+ val c = DConfig.data ?: return
+ val loc = currentLocationName ?: return
+ val idx = currentLocationSouls.indexOf(nearestSoul)
+ c.foundSouls.computeIfAbsent(loc) { mutableSetOf() }.add(idx)
+ DConfig.markDirty()
+ updateMissingSouls()
+ }
+
+ @Subscribe
+ fun onWorldRender(it: WorldRenderLastEvent) {
+ if (!TConfig.displaySouls) return
+ renderInWorld(it) {
+ color(1F, 1F, 0F, 0.8F)
+ currentMissingSouls.forEach {
+ block(it.blockPos)
+ }
+ color(1f, 0f, 1f, 1f)
+ currentLocationSouls.forEach {
+ wireframeCube(it.blockPos)
+ }
+ }
+ }
+
+ @Subscribe
+ fun onProcessChat(it: ProcessChatEvent) {
+ when (it.text.unformattedString) {
+ "You have already found that Fairy Soul!" -> {
+ markNearestSoul()
+ }
+
+ "SOUL! You found a Fairy Soul!" -> {
+ markNearestSoul()
+ }
+ }
+ }
+
+ @Subscribe
+ fun onLocationChange(it: SkyblockServerUpdateEvent) {
+ currentLocationName = it.newLocraw?.skyblockLocation
+ updateWorldSouls()
+ updateMissingSouls()
+ }
+}
diff --git a/src/main/kotlin/features/world/NPCWaypoints.kt b/src/main/kotlin/features/world/NPCWaypoints.kt
new file mode 100644
index 0000000..592b8fa
--- /dev/null
+++ b/src/main/kotlin/features/world/NPCWaypoints.kt
@@ -0,0 +1,40 @@
+package moe.nea.firmament.features.world
+
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.commands.thenExecute
+import moe.nea.firmament.events.CommandEvent
+import moe.nea.firmament.events.ReloadRegistrationEvent
+import moe.nea.firmament.util.MoulConfigUtils
+import moe.nea.firmament.util.ScreenUtil
+
+object NPCWaypoints {
+
+ var allNpcWaypoints = listOf<NavigableWaypoint>()
+
+ @Subscribe
+ fun onRepoReloadRegistration(event: ReloadRegistrationEvent) {
+ event.repo.registerReloadListener {
+ allNpcWaypoints = it.items.items.values
+ .asSequence()
+ .filter { !it.island.isNullOrBlank() }
+ .map {
+ NavigableWaypoint.NPCWaypoint(it)
+ }
+ .toList()
+ }
+ }
+
+ @Subscribe
+ fun onOpenGui(event: CommandEvent.SubCommand) {
+ event.subcommand("npcs") {
+ thenExecute {
+ ScreenUtil.setScreenLater(MoulConfigUtils.loadScreen(
+ "npc_waypoints",
+ NpcWaypointGui(allNpcWaypoints),
+ null))
+ }
+ }
+ }
+
+
+}
diff --git a/src/main/kotlin/features/world/NavigableWaypoint.kt b/src/main/kotlin/features/world/NavigableWaypoint.kt
new file mode 100644
index 0000000..28a517f
--- /dev/null
+++ b/src/main/kotlin/features/world/NavigableWaypoint.kt
@@ -0,0 +1,22 @@
+package moe.nea.firmament.features.world
+
+import io.github.moulberry.repo.data.NEUItem
+import net.minecraft.util.math.BlockPos
+import moe.nea.firmament.util.SkyBlockIsland
+
+abstract class NavigableWaypoint {
+ abstract val name: String
+ abstract val position: BlockPos
+ abstract val island: SkyBlockIsland
+
+ data class NPCWaypoint(
+ val item: NEUItem,
+ ) : NavigableWaypoint() {
+ override val name: String
+ get() = item.displayName
+ override val position: BlockPos
+ get() = BlockPos(item.x, item.y, item.z)
+ override val island: SkyBlockIsland
+ get() = SkyBlockIsland.forMode(item.island)
+ }
+}
diff --git a/src/main/kotlin/features/world/NavigationHelper.kt b/src/main/kotlin/features/world/NavigationHelper.kt
new file mode 100644
index 0000000..acdfb86
--- /dev/null
+++ b/src/main/kotlin/features/world/NavigationHelper.kt
@@ -0,0 +1,121 @@
+package moe.nea.firmament.features.world
+
+import io.github.moulberry.repo.constants.Islands
+import net.minecraft.text.Text
+import net.minecraft.util.math.BlockPos
+import net.minecraft.util.math.Position
+import net.minecraft.util.math.Vec3i
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.events.SkyblockServerUpdateEvent
+import moe.nea.firmament.events.TickEvent
+import moe.nea.firmament.events.WorldRenderLastEvent
+import moe.nea.firmament.repo.RepoManager
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.SBData
+import moe.nea.firmament.util.SkyBlockIsland
+import moe.nea.firmament.util.WarpUtil
+import moe.nea.firmament.util.render.RenderInWorldContext
+
+object NavigationHelper {
+ var targetWaypoint: NavigableWaypoint? = null
+ set(value) {
+ field = value
+ recalculateRoute()
+ }
+
+ var nextTeleporter: Islands.Teleporter? = null
+ private set
+
+ val Islands.Teleporter.toIsland get() = SkyBlockIsland.forMode(this.getTo())
+ val Islands.Teleporter.fromIsland get() = SkyBlockIsland.forMode(this.getFrom())
+ val Islands.Teleporter.blockPos get() = BlockPos(x.toInt(), y.toInt(), z.toInt())
+
+ @Subscribe
+ fun onWorldSwitch(event: SkyblockServerUpdateEvent) {
+ recalculateRoute()
+ }
+
+ fun recalculateRoute() {
+ val tp = targetWaypoint
+ val currentIsland = SBData.skyblockLocation
+ if (tp == null || currentIsland == null) {
+ nextTeleporter = null
+ return
+ }
+ val route = findRoute(currentIsland, tp.island, mutableSetOf())
+ nextTeleporter = route?.get(0)
+ }
+
+ private fun findRoute(
+ fromIsland: SkyBlockIsland,
+ targetIsland: SkyBlockIsland,
+ visitedIslands: MutableSet<SkyBlockIsland>
+ ): MutableList<Islands.Teleporter>? {
+ var shortestChain: MutableList<Islands.Teleporter>? = null
+ for (it in RepoManager.neuRepo.constants.islands.teleporters) {
+ if (it.toIsland in visitedIslands) continue
+ if (it.fromIsland != fromIsland) continue
+ if (it.toIsland == targetIsland) return mutableListOf(it)
+ visitedIslands.add(fromIsland)
+ val nextRoute = findRoute(it.toIsland, targetIsland, visitedIslands) ?: continue
+ nextRoute.add(0, it)
+ if (shortestChain == null || shortestChain.size > nextRoute.size) {
+ shortestChain = nextRoute
+ }
+ visitedIslands.remove(fromIsland)
+ }
+ return shortestChain
+ }
+
+
+ @Subscribe
+ fun onMovement(event: TickEvent) { // TODO: add a movement tick event maybe?
+ val tp = targetWaypoint ?: return
+ val p = MC.player ?: return
+ if (p.squaredDistanceTo(tp.position.toCenterPos()) < 5 * 5) {
+ targetWaypoint = null
+ }
+ }
+
+ @Subscribe
+ fun drawWaypoint(event: WorldRenderLastEvent) {
+ val tp = targetWaypoint ?: return
+ val nt = nextTeleporter
+ RenderInWorldContext.renderInWorld(event) {
+ if (nt != null) {
+ waypoint(nt.blockPos,
+ Text.literal("Teleporter to " + nt.toIsland.userFriendlyName),
+ Text.literal("(towards " + tp.name + "§f)"))
+ } else if (tp.island == SBData.skyblockLocation) {
+ waypoint(tp.position,
+ Text.literal(tp.name))
+ }
+ }
+ }
+
+ fun tryWarpNear() {
+ val tp = targetWaypoint
+ if (tp == null) {
+ MC.sendChat(Text.literal("Could not find a waypoint to warp you to. Select one first."))
+ return
+ }
+ WarpUtil.teleportToNearestWarp(tp.island, tp.position.asPositionView())
+ }
+
+}
+
+fun Vec3i.asPositionView(): Position {
+ return object : Position {
+ override fun getX(): Double {
+ return this@asPositionView.x.toDouble()
+ }
+
+ override fun getY(): Double {
+ return this@asPositionView.y.toDouble()
+ }
+
+ override fun getZ(): Double {
+ return this@asPositionView.z.toDouble()
+ }
+ }
+}
diff --git a/src/main/kotlin/features/world/NpcWaypointGui.kt b/src/main/kotlin/features/world/NpcWaypointGui.kt
new file mode 100644
index 0000000..6146e50
--- /dev/null
+++ b/src/main/kotlin/features/world/NpcWaypointGui.kt
@@ -0,0 +1,68 @@
+package moe.nea.firmament.features.world
+
+import io.github.notenoughupdates.moulconfig.observer.ObservableList
+import io.github.notenoughupdates.moulconfig.xml.Bind
+import moe.nea.firmament.features.events.anniversity.AnniversaryFeatures.atOnce
+import moe.nea.firmament.keybindings.SavedKeyBinding
+
+class NpcWaypointGui(
+ val allWaypoints: List<NavigableWaypoint>,
+) {
+
+ data class NavigableWaypointW(val waypoint: NavigableWaypoint) {
+ @Bind
+ fun name() = waypoint.name
+
+ @Bind
+ fun isSelected() = NavigationHelper.targetWaypoint == waypoint
+
+ @Bind
+ fun click() {
+ if (SavedKeyBinding.isShiftDown()) {
+ NavigationHelper.targetWaypoint = waypoint
+ NavigationHelper.tryWarpNear()
+ } else if (isSelected()) {
+ NavigationHelper.targetWaypoint = null
+ } else {
+ NavigationHelper.targetWaypoint = waypoint
+ }
+ }
+ }
+
+ @JvmField
+ @field:Bind
+ var search: String = ""
+ var lastSearch: String? = null
+
+ @Bind("results")
+ fun results(): ObservableList<NavigableWaypointW> {
+ return results
+ }
+
+ @Bind
+ fun tick() {
+ if (search != lastSearch) {
+ updateSearch()
+ lastSearch = search
+ }
+ }
+
+ val results: ObservableList<NavigableWaypointW> = ObservableList(mutableListOf())
+
+ fun updateSearch() {
+ val split = search.split(" +".toRegex())
+ results.atOnce {
+ results.clear()
+ allWaypoints.filter { waypoint ->
+ if (search.isBlank()) {
+ true
+ } else {
+ split.all { waypoint.name.contains(it, ignoreCase = true) }
+ }
+ }.mapTo(results) {
+ NavigableWaypointW(it)
+ }
+ }
+ }
+
+}
diff --git a/src/main/kotlin/features/world/Waypoints.kt b/src/main/kotlin/features/world/Waypoints.kt
new file mode 100644
index 0000000..91a06da
--- /dev/null
+++ b/src/main/kotlin/features/world/Waypoints.kt
@@ -0,0 +1,297 @@
+
+
+package moe.nea.firmament.features.world
+
+import com.mojang.brigadier.arguments.IntegerArgumentType
+import me.shedaniel.math.Color
+import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource
+import kotlinx.serialization.Serializable
+import kotlin.collections.component1
+import kotlin.collections.component2
+import kotlin.collections.set
+import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.seconds
+import net.minecraft.command.argument.BlockPosArgumentType
+import net.minecraft.server.command.ServerCommandSource
+import net.minecraft.text.Text
+import net.minecraft.util.math.BlockPos
+import net.minecraft.util.math.Vec3d
+import moe.nea.firmament.Firmament
+import moe.nea.firmament.annotations.Subscribe
+import moe.nea.firmament.commands.get
+import moe.nea.firmament.commands.thenArgument
+import moe.nea.firmament.commands.thenExecute
+import moe.nea.firmament.commands.thenLiteral
+import moe.nea.firmament.events.CommandEvent
+import moe.nea.firmament.events.ProcessChatEvent
+import moe.nea.firmament.events.TickEvent
+import moe.nea.firmament.events.WorldReadyEvent
+import moe.nea.firmament.events.WorldRenderLastEvent
+import moe.nea.firmament.features.FirmamentFeature
+import moe.nea.firmament.gui.config.ManagedConfig
+import moe.nea.firmament.util.ClipboardUtils
+import moe.nea.firmament.util.MC
+import moe.nea.firmament.util.TimeMark
+import moe.nea.firmament.util.render.RenderInWorldContext
+
+object Waypoints : FirmamentFeature {
+ override val identifier: String
+ get() = "waypoints"
+
+ object TConfig : ManagedConfig(identifier) {
+ val tempWaypointDuration by duration("temp-waypoint-duration", 0.seconds, 1.hours) { 30.seconds }
+ val showIndex by toggle("show-index") { true }
+ val skipToNearest by toggle("skip-to-nearest") { false }
+ // TODO: look ahead size
+ }
+
+ data class TemporaryWaypoint(
+ val pos: BlockPos,
+ val postedAt: TimeMark,
+ )
+
+ override val config get() = TConfig
+
+ val temporaryPlayerWaypointList = mutableMapOf<String, TemporaryWaypoint>()
+ val temporaryPlayerWaypointMatcher = "(?i)x: (-?[0-9]+),? y: (-?[0-9]+),? z: (-?[0-9]+)".toPattern()
+
+ val waypoints = mutableListOf<BlockPos>()
+ var ordered = false
+ var orderedIndex = 0
+
+ @Serializable
+ data class ColeWeightWaypoint(
+ val x: Int,
+ val y: Int,
+ val z: Int,
+ val r: Int = 0,
+ val g: Int = 0,
+ val b: Int = 0,
+ )
+
+ @Subscribe
+ fun onRenderOrderedWaypoints(event: WorldRenderLastEvent) {
+ if (waypoints.isEmpty()) return
+ RenderInWorldContext.renderInWorld(event) {
+ if (!ordered) {
+ waypoints.withIndex().forEach {
+ color(0f, 0.3f, 0.7f, 0.5f)
+ block(it.value)
+ color(1f, 1f, 1f, 1f)
+ if (TConfig.showIndex)
+ withFacingThePlayer(it.value.toCenterPos()) {
+ text(Text.literal(it.index.toString()))
+ }
+ }
+ } else {
+ orderedIndex %= waypoints.size
+ val firstColor = Color.ofRGBA(0, 200, 40, 180)
+ color(firstColor)
+ tracer(waypoints[orderedIndex].toCenterPos(), lineWidth = 3f)
+ waypoints.withIndex().toList()
+ .wrappingWindow(orderedIndex, 3)
+ .zip(
+ listOf(
+ firstColor,
+ Color.ofRGBA(180, 200, 40, 150),
+ Color.ofRGBA(180, 80, 20, 140),
+ )
+ )
+ .reversed()
+ .forEach { (waypoint, col) ->
+ val (index, pos) = waypoint
+ color(col)
+ block(pos)
+ color(1f, 1f, 1f, 1f)
+ if (TConfig.showIndex)
+ withFacingThePlayer(pos.toCenterPos()) {
+ text(Text.literal(index.toString()))
+ }
+ }
+ }
+ }
+ }
+
+ @Subscribe
+ fun onTick(event: TickEvent) {
+ if (waypoints.isEmpty() || !ordered) return
+ orderedIndex %= waypoints.size
+ val p = MC.player?.pos ?: return
+ if (TConfig.skipToNearest) {
+ orderedIndex =
+ (waypoints.withIndex().minBy { it.value.getSquaredDistance(p) }.index + 1) % waypoints.size
+ } else {
+ if (waypoints[orderedIndex].isWithinDistance(p, 3.0)) {
+ orderedIndex = (orderedIndex + 1) % waypoints.size
+ }
+ }
+ }
+
+ @Subscribe
+ fun onProcessChat(it: ProcessChatEvent) {
+ val matcher = temporaryPlayerWaypointMatcher.matcher(it.unformattedString)
+ if (it.nameHeuristic != null && TConfig.tempWaypointDuration > 0.seconds && matcher.find()) {
+ temporaryPlayerWaypointList[it.nameHeuristic] = TemporaryWaypoint(
+ BlockPos(
+ matcher.group(1).toInt(),
+ matcher.group(2).toInt(),
+ matcher.group(3).toInt(),
+ ),
+ TimeMark.now()
+ )
+ }
+ }
+
+ @Subscribe
+ fun onCommand(event: CommandEvent.SubCommand) {
+ event.subcommand("waypoint") {
+ thenArgument("pos", BlockPosArgumentType.blockPos()) { pos ->
+ thenExecute {
+ val position = pos.get(this).toAbsoluteBlockPos(source.asFakeServer())
+ waypoints.add(position)
+ source.sendFeedback(
+ Text.stringifiedTranslatable(
+ "firmament.command.waypoint.added",
+ position.x,
+ position.y,
+ position.z
+ )
+ )
+ }
+ }
+ }
+ event.subcommand("waypoints") {
+ thenLiteral("clear") {
+ thenExecute {
+ waypoints.clear()
+ source.sendFeedback(Text.translatable("firmament.command.waypoint.clear"))
+ }
+ }
+ thenLiteral("toggleordered") {
+ thenExecute {
+ ordered = !ordered
+ if (ordered) {
+ val p = MC.player?.pos ?: Vec3d.ZERO
+ orderedIndex =
+ waypoints.withIndex().minByOrNull { it.value.getSquaredDistance(p) }?.index ?: 0
+ }
+ source.sendFeedback(Text.translatable("firmament.command.waypoint.ordered.toggle.$ordered"))
+ }
+ }
+ thenLiteral("skip") {
+ thenExecute {
+ if (ordered && waypoints.isNotEmpty()) {
+ orderedIndex = (orderedIndex + 1) % waypoints.size
+ source.sendFeedback(Text.translatable("firmament.command.waypoint.skip"))
+ } else {
+ source.sendError(Text.translatable("firmament.command.waypoint.skip.error"))
+ }
+ }
+ }
+ thenLiteral("remove") {
+ thenArgument("index", IntegerArgumentType.integer(0)) { indexArg ->
+ thenExecute {
+ val index = get(indexArg)
+ if (index in waypoints.indices) {
+ waypoints.removeAt(index)
+ source.sendFeedback(Text.stringifiedTranslatable(
+ "firmament.command.waypoint.remove",
+ index))
+ } else {
+ source.sendError(Text.stringifiedTranslatable("firmament.command.waypoint.remove.error"))
+ }
+ }
+ }
+ }
+ thenLiteral("import") {
+ thenExecute {
+ val contents = ClipboardUtils.getTextContents()
+ val data = try {
+ Firmament.json.decodeFromString<List<ColeWeightWaypoint>>(contents)
+ } catch (ex: Exception) {
+ Firmament.logger.error("Could not load waypoints from clipboard", ex)
+ source.sendError(Text.translatable("firmament.command.waypoint.import.error"))
+ return@thenExecute
+ }
+ waypoints.clear()
+ data.mapTo(waypoints) { BlockPos(it.x, it.y, it.z) }
+ source.sendFeedback(
+ Text.stringifiedTranslatable(
+ "firmament.command.waypoint.import",
+ data.size
+ )
+ )
+ }
+ }
+ }
+ }
+
+ @Subscribe
+ fun onRenderTemporaryWaypoints(event: WorldRenderLastEvent) {
+ temporaryPlayerWaypointList.entries.removeIf { it.value.postedAt.passedTime() > TConfig.tempWaypointDuration }
+ if (temporaryPlayerWaypointList.isEmpty()) return
+ RenderInWorldContext.renderInWorld(event) {
+ color(1f, 1f, 0f, 1f)
+ temporaryPlayerWaypointList.forEach { (player, waypoint) ->
+ block(waypoint.pos)
+ }
+ color(1f, 1f, 1f, 1f)
+ temporaryPlayerWaypointList.forEach { (player, waypoint) ->
+ val skin =
+ MC.networkHandler?.listedPlayerListEntries?.find { it.profile.name == player }
+ ?.skinTextures
+ ?.texture
+ withFacingThePlayer(waypoint.pos.toCenterPos()) {
+ waypoint(waypoint.pos, Text.stringifiedTranslatable("firmament.waypoint.temporary", player))
+ if (skin != null) {
+ matrixStack.translate(0F, -20F, 0F)
+ // Head front
+ texture(
+ skin, 16, 16,
+ 1 / 8f, 1 / 8f,
+ 2 / 8f, 2 / 8f,
+ )
+ // Head overlay
+ texture(
+ skin, 16, 16,
+ 5 / 8f, 1 / 8f,
+ 6 / 8f, 2 / 8f,
+ )
+ }
+ }
+ }
+ }
+ }
+
+ @Subscribe
+ fun onWorldReady(event: WorldReadyEvent) {
+ temporaryPlayerWaypointList.clear()
+ }
+}
+
+fun <E> List<E>.wrappingWindow(startIndex: Int, windowSize: Int): List<E> {
+ val result = ArrayList<E>(windowSize)
+ if (startIndex + windowSize < size) {
+ result.addAll(subList(startIndex, startIndex + windowSize))
+ } else {
+ result.addAll(subList(startIndex, size))
+ result.addAll(subList(0, minOf(windowSize - (size - startIndex), startIndex)))
+ }
+ return result
+}
+
+
+fun FabricClientCommandSource.asFakeServer(): ServerCommandSource {
+ val source = this
+ return ServerCommandSource(
+ source.player,
+ source.position,
+ source.rotation,
+ null,
+ 0,
+ "FakeServerCommandSource",
+ Text.literal("FakeServerCommandSource"),
+ null,
+ source.player
+ )
+}