diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 000000000..14f22c4e7 --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index 1f044c595..8a39c187d 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -5,6 +5,7 @@ armorposer birdflop cmds + coord decentholograms decentsoftware enderman @@ -19,6 +20,7 @@ invs invsearch invu + invunload jpenilla ktlint mojang @@ -28,6 +30,7 @@ redstone rrggbb searchinv + shulker signedit spellbite tellraw diff --git a/.idea/libraries/Gradle__com_velocitypowered_velocity_native_3_4_0_SNAPSHOT.xml b/.idea/libraries/Gradle__com_velocitypowered_velocity_native_3_4_0_SNAPSHOT.xml index 7f613a1a8..89cfc6ea4 100644 --- a/.idea/libraries/Gradle__com_velocitypowered_velocity_native_3_4_0_SNAPSHOT.xml +++ b/.idea/libraries/Gradle__com_velocitypowered_velocity_native_3_4_0_SNAPSHOT.xml @@ -2,7 +2,7 @@ - + diff --git a/.idea/libraries/Gradle__io_papermc_paper_paper_api_1_21_10_R0_1_SNAPSHOT.xml b/.idea/libraries/Gradle__io_papermc_paper_paper_api_1_21_10_R0_1_SNAPSHOT.xml index 07eaf6178..9cef7876f 100644 --- a/.idea/libraries/Gradle__io_papermc_paper_paper_api_1_21_10_R0_1_SNAPSHOT.xml +++ b/.idea/libraries/Gradle__io_papermc_paper_paper_api_1_21_10_R0_1_SNAPSHOT.xml @@ -2,11 +2,11 @@ - + - + \ No newline at end of file diff --git a/.idea/modules/VanillaPlus.main.iml b/.idea/modules/VanillaPlus.main.iml index 5674d84be..dde4fd273 100644 --- a/.idea/modules/VanillaPlus.main.iml +++ b/.idea/modules/VanillaPlus.main.iml @@ -12,7 +12,7 @@ - + @@ -56,7 +56,7 @@ - + diff --git a/.idea/modules/VanillaPlus.test.iml b/.idea/modules/VanillaPlus.test.iml index 887be16db..832085095 100644 --- a/.idea/modules/VanillaPlus.test.iml +++ b/.idea/modules/VanillaPlus.test.iml @@ -12,7 +12,7 @@ - + VanillaPlus:main @@ -54,7 +54,7 @@ - + diff --git a/src/main/kotlin/org/xodium/vanillaplus/data/BookData.kt b/src/main/kotlin/org/xodium/vanillaplus/data/BookData.kt index 5e189c722..2672cde75 100644 --- a/src/main/kotlin/org/xodium/vanillaplus/data/BookData.kt +++ b/src/main/kotlin/org/xodium/vanillaplus/data/BookData.kt @@ -2,21 +2,23 @@ package org.xodium.vanillaplus.data import net.kyori.adventure.inventory.Book import org.bukkit.permissions.PermissionDefault +import org.xodium.vanillaplus.VanillaPlus.Companion.instance import org.xodium.vanillaplus.utils.ExtUtils.mm +import org.xodium.vanillaplus.utils.FmtUtils.fireFmt /** * Represents the data structure for a book in the game. * @property cmd The command associated with the book. * @property permission The default permission level for this book's command (defaults to TRUE). - * @property title The [title] of the book. - * @property author The [author] of the book. + * @property title The [title] of the book. Defaults to the command name with the first letter capitalized and formatted. + * @property author The [author] of the book. Defaults to the name of the main plugin instance class. * @property pages The content of the book, represented as a list of [pages], where each page is a list of lines. */ internal data class BookData( val cmd: String, val permission: PermissionDefault = PermissionDefault.TRUE, - private val title: String, - private val author: String, + private val title: String = cmd.replaceFirstChar { it.uppercase() }.fireFmt(), + private val author: String = instance::class.simpleName.toString(), private val pages: List>, ) { /** diff --git a/src/main/kotlin/org/xodium/vanillaplus/engines/ExpressionEngine.kt b/src/main/kotlin/org/xodium/vanillaplus/engines/ExpressionEngine.kt deleted file mode 100644 index df875c96b..000000000 --- a/src/main/kotlin/org/xodium/vanillaplus/engines/ExpressionEngine.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.xodium.vanillaplus.engines - -import org.mariuszgromada.math.mxparser.Argument -import org.mariuszgromada.math.mxparser.Expression - -/** - * A mathematical expression engine that safely evaluates string expressions using mXparser. - * @see org.mariuszgromada.math.mxparser.Expression - * @see org.mariuszgromada.math.mxparser.Argument - */ -internal object ExpressionEngine { - /** - * Evaluates a mathematical expression with the provided variable context. - * @param expression the mathematical expression to evaluate (e.g. "speed * 10 + jump * 5"). - * @param context a map of variable names to their values for expression substitution. - * @return the computed numerical result of the expression. - * @throws IllegalArgumentException if: - * — Expression contains forbidden characters (`;`, `{`, `}`, `[`, `]`, `"`) :cite[1] - * — Expression syntax is invalid - * — Result is not a valid number (NaN or infinite) - */ - fun evaluate( - expression: String, - context: Map, - ): Double { - if (expression.contains(Regex("""[;{}\[\]"]"""))) { - throw IllegalArgumentException("Expression contains forbidden characters") - } - - val expr = Expression(expression) - - context.forEach { (key, value) -> expr.addArguments(Argument("$key = $value")) } - - val result = expr.calculate() - - if (expr.checkSyntax()) { - if (result.isNaN() || result.isInfinite()) { - throw IllegalArgumentException("Expression result is not a valid number") - } - return result - } else { - throw IllegalArgumentException("Syntax error in expression: ${expr.errorMessage}") - } - } -} diff --git a/src/main/kotlin/org/xodium/vanillaplus/interfaces/DataInterface.kt b/src/main/kotlin/org/xodium/vanillaplus/interfaces/DataInterface.kt index 3c5fdc631..1928ceede 100644 --- a/src/main/kotlin/org/xodium/vanillaplus/interfaces/DataInterface.kt +++ b/src/main/kotlin/org/xodium/vanillaplus/interfaces/DataInterface.kt @@ -12,7 +12,7 @@ import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.KotlinModule import org.xodium.vanillaplus.VanillaPlus.Companion.instance -import org.xodium.vanillaplus.utils.ExtUtils.toSnakeCase +import org.xodium.vanillaplus.utils.ExtUtils.snakeCase import java.io.IOException import java.nio.file.Path import kotlin.io.path.createDirectories @@ -24,7 +24,7 @@ interface DataInterface { val dataClass: KClass val cache: MutableMap val fileName: String - get() = "${dataClass.simpleName?.toSnakeCase()}.json" + get() = "${dataClass.simpleName?.snakeCase}.json" val filePath: Path get() = instance.dataFolder.toPath().resolve(fileName) val jsonMapper: ObjectMapper diff --git a/src/main/kotlin/org/xodium/vanillaplus/managers/ConfigManager.kt b/src/main/kotlin/org/xodium/vanillaplus/managers/ConfigManager.kt index 6025a2d84..631efe340 100644 --- a/src/main/kotlin/org/xodium/vanillaplus/managers/ConfigManager.kt +++ b/src/main/kotlin/org/xodium/vanillaplus/managers/ConfigManager.kt @@ -19,7 +19,7 @@ internal object ConfigManager : DataInterface { */ fun update(modules: List>) { modules.forEach { module -> - val key = module.key() + val key = module.key val fileConfig = readFileConfig(key, module) val mergedConfig = fileConfig?.let { jsonMapper.updateValue(module.config, it) } ?: module.config set(key, mergedConfig) diff --git a/src/main/kotlin/org/xodium/vanillaplus/modules/BooksModule.kt b/src/main/kotlin/org/xodium/vanillaplus/modules/BooksModule.kt index 72c0556f6..70e1c7734 100644 --- a/src/main/kotlin/org/xodium/vanillaplus/modules/BooksModule.kt +++ b/src/main/kotlin/org/xodium/vanillaplus/modules/BooksModule.kt @@ -3,13 +3,11 @@ package org.xodium.vanillaplus.modules import io.papermc.paper.command.brigadier.Commands import org.bukkit.entity.Player import org.bukkit.permissions.Permission -import org.bukkit.permissions.PermissionDefault import org.xodium.vanillaplus.VanillaPlus.Companion.instance import org.xodium.vanillaplus.data.BookData import org.xodium.vanillaplus.data.CommandData import org.xodium.vanillaplus.interfaces.ModuleInterface import org.xodium.vanillaplus.utils.ExtUtils.tryCatch -import org.xodium.vanillaplus.utils.FmtUtils.fireFmt /** Represents a module handling book mechanics within the system. */ internal class BooksModule : ModuleInterface { @@ -49,9 +47,6 @@ internal class BooksModule : ModuleInterface { listOf( BookData( cmd = "rules", - permission = PermissionDefault.TRUE, - title = "Rules".fireFmt(), - author = instance::class.simpleName.toString(), pages = listOf( // Page 1: Player Rules (1-7) diff --git a/src/main/kotlin/org/xodium/vanillaplus/modules/ChatModule.kt b/src/main/kotlin/org/xodium/vanillaplus/modules/ChatModule.kt index 67e6ba915..8e16080e0 100644 --- a/src/main/kotlin/org/xodium/vanillaplus/modules/ChatModule.kt +++ b/src/main/kotlin/org/xodium/vanillaplus/modules/ChatModule.kt @@ -5,6 +5,7 @@ import com.mojang.brigadier.arguments.StringArgumentType import io.papermc.paper.chat.ChatRenderer import io.papermc.paper.command.brigadier.Commands import io.papermc.paper.event.player.AsyncChatEvent +import net.kyori.adventure.chat.SignedMessage import net.kyori.adventure.text.Component import net.kyori.adventure.text.event.ClickEvent import net.kyori.adventure.text.event.HoverEvent @@ -18,7 +19,9 @@ import org.bukkit.permissions.PermissionDefault import org.xodium.vanillaplus.VanillaPlus.Companion.instance import org.xodium.vanillaplus.data.CommandData import org.xodium.vanillaplus.interfaces.ModuleInterface +import org.xodium.vanillaplus.utils.ExtUtils.clickOpenUrl import org.xodium.vanillaplus.utils.ExtUtils.clickRunCmd +import org.xodium.vanillaplus.utils.ExtUtils.clickSuggestCmd import org.xodium.vanillaplus.utils.ExtUtils.face import org.xodium.vanillaplus.utils.ExtUtils.mm import org.xodium.vanillaplus.utils.ExtUtils.prefix @@ -70,25 +73,6 @@ internal class ChatModule : ModuleInterface { "This command allows you to whisper to players", listOf("w", "msg", "tell", "tellraw"), ), - CommandData( - Commands - .literal("guide") - .requires { it.sender.hasPermission(perms()[1]) } - .executes { ctx -> - ctx.tryCatch { - if (it.sender !is Player) instance.logger.warning("Command can only be executed by a Player!") - val sender = it.sender as Player - sender.sendMessage( - instance.prefix + - "Click me to open url!".mangoFmt(true).mm().clickEvent( - ClickEvent.openUrl("https://illyria.fandom.com/"), - ), - ) - } - }, - "This command redirects you to the wiki", - emptyList(), - ), ) } @@ -99,11 +83,6 @@ internal class ChatModule : ModuleInterface { "Allows use of the whisper command", PermissionDefault.TRUE, ), - Permission( - "${instance::class.simpleName}.guide".lowercase(), - "Allows use of the guide command", - PermissionDefault.TRUE, - ), ) @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) @@ -122,9 +101,9 @@ internal class ChatModule : ModuleInterface { .clickEvent(ClickEvent.suggestCommand("/w ${player.name} ")) .hoverEvent(HoverEvent.showText(config.i18n.clickToWhisper.mm())), ), - Placeholder.component("message", message.pt().mm()), + Placeholder.component("message", message), ) - if (audience == player) base = base.appendSpace().append(createDeleteCross(event)) + if (audience == player) base = base.appendSpace().append(createDeleteCross(event.signedMessage())) base } } @@ -139,13 +118,7 @@ internal class ChatModule : ModuleInterface { Regex("") .replace(config.welcomeText.joinToString("\n")) { "" } .mm( - Placeholder.component( - "player", - player - .displayName() - .clickEvent(ClickEvent.suggestCommand("/nickname ${player.name}")) - .hoverEvent(HoverEvent.showText(config.i18n.clickMe.mm())), - ), + Placeholder.component("player", player.displayName()), *player .face() .lines() @@ -203,14 +176,14 @@ internal class ChatModule : ModuleInterface { /** * Creates to delete cross-component for message deletion. - * @param event The [AsyncChatEvent] containing the message to be deleted. + * @param signedMessage The signed message to be deleted. * @return A [Component] representing the delete cross with hover text and click action. */ - private fun createDeleteCross(event: AsyncChatEvent): Component = + private fun createDeleteCross(signedMessage: SignedMessage): Component = config.deleteCross .mm() .hoverEvent(config.i18n.deleteMessage.mm()) - .clickEvent(ClickEvent.callback { instance.server.deleteMessage(event.signedMessage()) }) + .clickEvent(ClickEvent.callback { instance.server.deleteMessage(signedMessage) }) data class Config( override var enabled: Boolean = true, @@ -220,15 +193,26 @@ internal class ChatModule : ModuleInterface { "]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[".mangoFmt(true), "${"⯈".mangoFmt(true)}", "${"⯈".mangoFmt(true)}", - "${"⯈".mangoFmt(true)} ${"Welcome".fireFmt()} ", - "${"⯈".mangoFmt(true)}", - "${"⯈".mangoFmt(true)}", - "${"⯈".mangoFmt(true)} ${"Check out".fireFmt()}: ${ - "/rules".clickRunCmd("Click Me!".fireFmt()).skylineFmt() - } 🟅 ${ - "/guide".clickRunCmd("Click Me!".fireFmt()).skylineFmt() + "${"⯈".mangoFmt(true)} ${"Welcome".fireFmt()} ${ + "".clickSuggestCmd( + "/nickname ", + "Set your nickname!".mangoFmt(), + ) }", "${"⯈".mangoFmt(true)}", + "${"⯈".mangoFmt(true)} ${"Check out".fireFmt()}:", + "${"⯈".mangoFmt(true)} ${ + "".clickRunCmd( + "/rules", + "View the server /rules".mangoFmt(), + ) + }", + "${"⯈".mangoFmt(true)} ${ + "".clickOpenUrl( + "https://illyria.fandom.com", + "Visit the wiki!".mangoFmt(), + ) + }", "${"⯈".mangoFmt(true)}", "]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[=]|[".mangoFmt(true), ), @@ -240,11 +224,11 @@ internal class ChatModule : ModuleInterface { var i18n: I18n = I18n(), ) : ModuleInterface.Config { data class I18n( - var clickMe: String = "Click Me!".fireFmt(), - var clickToWhisper: String = "Click to Whisper".fireFmt(), + var clickMe: String = "Click me!".mangoFmt(), + var clickToWhisper: String = "Click to Whisper".mangoFmt(), var playerIsNotOnline: String = "${instance.prefix} Player is not Online!".fireFmt(), - var deleteMessage: String = "Click to delete your message".fireFmt(), - var clickToClipboard: String = "Click to copy position to clipboard".fireFmt(), + var deleteMessage: String = "Click to delete your message".mangoFmt(), + var clickToClipboard: String = "Click to copy position to clipboard".mangoFmt(), var playerSetSpawn: String = "${"❗".fireFmt()} ${"›".mangoFmt(true)} ", ) } diff --git a/src/main/kotlin/org/xodium/vanillaplus/modules/InvModule.kt b/src/main/kotlin/org/xodium/vanillaplus/modules/InvModule.kt index feb1f88a6..829da7402 100644 --- a/src/main/kotlin/org/xodium/vanillaplus/modules/InvModule.kt +++ b/src/main/kotlin/org/xodium/vanillaplus/modules/InvModule.kt @@ -2,36 +2,39 @@ package org.xodium.vanillaplus.modules -import com.destroystokyo.paper.ParticleBuilder import com.mojang.brigadier.arguments.StringArgumentType import com.mojang.brigadier.context.CommandContext import io.papermc.paper.command.brigadier.CommandSourceStack import io.papermc.paper.command.brigadier.Commands import net.kyori.adventure.sound.Sound import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder -import org.bukkit.* -import org.bukkit.block.* +import org.bukkit.Color +import org.bukkit.Material +import org.bukkit.Particle +import org.bukkit.block.Block +import org.bukkit.block.Container import org.bukkit.entity.Player import org.bukkit.event.EventHandler import org.bukkit.event.EventPriority import org.bukkit.event.player.PlayerQuitEvent -import org.bukkit.inventory.Inventory -import org.bukkit.inventory.InventoryHolder import org.bukkit.inventory.ItemStack -import org.bukkit.inventory.meta.EnchantmentStorageMeta import org.bukkit.permissions.Permission import org.bukkit.permissions.PermissionDefault -import org.bukkit.util.BoundingBox import org.xodium.vanillaplus.VanillaPlus.Companion.instance import org.xodium.vanillaplus.data.CommandData import org.xodium.vanillaplus.data.SoundData import org.xodium.vanillaplus.interfaces.ModuleInterface import org.xodium.vanillaplus.registries.MaterialRegistry +import org.xodium.vanillaplus.utils.BlockUtils.center +import org.xodium.vanillaplus.utils.ChunkUtils.filterAndSortContainers +import org.xodium.vanillaplus.utils.ChunkUtils.findContainersInRadius import org.xodium.vanillaplus.utils.ExtUtils.mm import org.xodium.vanillaplus.utils.ExtUtils.tryCatch import org.xodium.vanillaplus.utils.FmtUtils.fireFmt import org.xodium.vanillaplus.utils.FmtUtils.glorpFmt import org.xodium.vanillaplus.utils.FmtUtils.roseFmt +import org.xodium.vanillaplus.utils.InvUtils.transferItems +import org.xodium.vanillaplus.utils.ItemStackUtils import java.util.* import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap @@ -41,8 +44,6 @@ import org.bukkit.Sound as BukkitSound internal class InvModule : ModuleInterface { override val config: Config = Config() - private val unloads = ConcurrentHashMap>() - private val lastUnloads = ConcurrentHashMap>() private val activeVisualizations = ConcurrentHashMap>() override fun cmds(): List = @@ -103,9 +104,7 @@ internal class InvModule : ModuleInterface { fun on(event: PlayerQuitEvent) { if (!enabled()) return - val uuid = event.player.uniqueId - lastUnloads.remove(uuid) - activeVisualizations.remove(uuid) + activeVisualizations.remove(event.player.uniqueId) } /** @@ -118,12 +117,14 @@ internal class InvModule : ModuleInterface { val materialName = runCatching { StringArgumentType.getString(ctx, "material") }.getOrNull() val material = materialName?.let { Material.getMaterial(it.uppercase()) } ?: player.inventory.itemInMainHand.type + if (material == Material.AIR) { player.sendActionBar(config.i18n.noMaterialSpecified.mm()) return 0 } search(player, material) + return 1 } @@ -141,41 +142,67 @@ internal class InvModule : ModuleInterface { activeVisualizations.remove(player.uniqueId) } - val chests = - findBlocksInRadius(player.location, config.searchRadius) - .filter { it.state is Container } - .filter { searchItemInContainers(material, (it.state as Container).inventory) } + val containers = + findContainersInRadius( + location = player.location, + radius = config.searchRadius, + containerTypes = MaterialRegistry.CONTAINER_TYPES, + ) + val matchingContainers = + containers.filter { block -> + val state = block.state as? Container ?: return@filter false + state.inventory.contents.any { item -> + item?.type == material && ItemStackUtils.hasMatchingEnchantments(ItemStack.of(material), item) + } + } - if (chests.isEmpty()) { - return player.sendActionBar( + if (matchingContainers.isEmpty()) { + player.sendActionBar( config.i18n.noMatchingItems.mm(Placeholder.component("material", material.name.mm())), ) + return } - val seenDoubleChests = mutableSetOf() - val filteredChests = - chests.filter { block -> - val inventory = (block.state as Container).inventory - val holder = inventory.holder - if (holder is DoubleChest && !seenDoubleChests.add(holder.leftSide)) return@filter false - true - } + val sortedChests = filterAndSortContainers(matchingContainers, player.location) - val sortedChests = filteredChests.sortedBy { it.location.distanceSquared(player.location) } if (sortedChests.isEmpty()) return val closestChest = sortedChests.first() + schedulePlayerTask(player, { - searchEffect(player.location, closestChest.center(), Color.MAROON, 40, player) - chestEffect(closestChest.center(), 10, Particle.DustOptions(Color.MAROON, 5.0f), player) + Particle.TRAIL + .builder() + .location(player.location) + .data(Particle.Trail(closestChest.center, Color.MAROON, 40)) + .receivers(player) + .spawn() + Particle.DUST + .builder() + .location(closestChest.center) + .count(10) + .data(Particle.DustOptions(Color.MAROON, 5.0f)) + .receivers(player) + .spawn() }) val otherChests = sortedChests.drop(1) + if (otherChests.isNotEmpty()) { schedulePlayerTask(player, { otherChests.forEach { - searchEffect(player.location, it.center(), Color.RED, 40, player) - chestEffect(it.center(), 10, Particle.DustOptions(Color.RED, 5.0f), player) + Particle.TRAIL + .builder() + .location(player.location) + .data(Particle.Trail(it.center, Color.RED, 40)) + .receivers(player) + .spawn() + Particle.DUST + .builder() + .location(it.center) + .count(10) + .data(Particle.DustOptions(Color.RED, 5.0f)) + .receivers(player) + .spawn() } }) } @@ -188,101 +215,55 @@ internal class InvModule : ModuleInterface { private fun unload(player: Player) { val startSlot = 9 val endSlot = 35 - val chests = - findBlocksInRadius(player.location, config.unloadRadius) - .filter { it.state is Container } - .sortedBy { it.location.distanceSquared(player.location) } - if (chests.isEmpty()) return player.sendActionBar(config.i18n.noNearbyChests.mm()) + val containers = + findContainersInRadius( + location = player.location, + radius = config.unloadRadius, + containerTypes = MaterialRegistry.CONTAINER_TYPES, + ) + val sortedChests = filterAndSortContainers(containers, player.location) - val affectedChests = mutableListOf() - for (block in chests) { - val inv = (block.state as Container).inventory - if (stuffInventoryIntoAnother(player, inv, true, startSlot, endSlot)) affectedChests.add(block) + if (sortedChests.isEmpty()) { + player.sendActionBar(config.i18n.noNearbyChests.mm()) + return } - if (affectedChests.isEmpty()) return player.sendActionBar(config.i18n.noItemsUnloaded.mm()) - - player.sendActionBar(config.i18n.inventoryUnloaded.mm()) - lastUnloads[player.uniqueId] = affectedChests - - for (chest in affectedChests) chestEffect(chest.center(), 10, Particle.DustOptions(Color.LIME, 5.0f), player) + val affectedChests = mutableListOf() - player.playSound(config.soundOnUnload.toSound(), Sound.Emitter.self()) - } + for (block in sortedChests) { + val destination = (block.state as? Container)?.inventory ?: continue + val transfer = + transferItems( + source = player.inventory, + destination = destination, + startSlot = startSlot, + endSlot = endSlot, + onlyMatching = true, + enchantmentChecker = ItemStackUtils::hasMatchingEnchantments, + ) + + if (transfer) affectedChests.add(block) + } - /** - * Moves items from the player's inventory to another inventory. - * @param player The player whose inventory is being moved. - * @param destination The destination inventory to move items into. - * @param onlyMatchingStuff If true, only moves items that match the destination's contents. - * @param startSlot The starting slot in the player's inventory to move items from. - * @param endSlot The ending slot in the player's inventory to move items from. - * @return True if items were moved, false otherwise. - */ - private fun stuffInventoryIntoAnother( - player: Player, - destination: Inventory, - onlyMatchingStuff: Boolean, - startSlot: Int, - endSlot: Int, - ): Boolean { - val source = player.inventory - val initialCount = countInventoryContents(source) - var moved = false - - for (i in startSlot..endSlot) { - val item = source.getItem(i) ?: continue - if (Tag.SHULKER_BOXES.isTagged(item.type) && destination.holder is ShulkerBox) continue - if (onlyMatchingStuff && !doesChestContain(destination, item)) continue - - val leftovers = destination.addItem(item) - val movedAmount = item.amount - leftovers.values.sumOf { it.amount } - if (movedAmount > 0) { - moved = true - source.clear(i) - leftovers.values.firstOrNull()?.let { source.setItem(i, it) } - destination.location?.let { protocolUnload(it, item.type, movedAmount) } - } + if (affectedChests.isEmpty()) { + player.sendActionBar(config.i18n.noItemsUnloaded.mm()) + return } - return moved && initialCount != countInventoryContents(source) - } - /** - * Counts the total number of items in the given inventory. - * @param inventory The inventory to count items in. - * @return The total number of items in the inventory. - */ - private fun countInventoryContents(inventory: Inventory): Int = inventory.contents.filterNotNull().sumOf { it.amount } + player.sendActionBar(config.i18n.inventoryUnloaded.mm()) - /** - * Searches for a specific item in the given inventory and its containers. - * @param material The material to search for. - * @param destination The inventory to search in. - * @return True if the item was found in the inventory or its containers, false otherwise. - */ - private fun searchItemInContainers( - material: Material, - destination: Inventory, - ): Boolean { - val item = ItemStack.of(material) - val count = doesChestContainCount(destination, material) - if (count > 0 && doesChestContain(destination, item)) { - destination.location?.let { protocolUnload(it, material, count) } - return true + for (chest in affectedChests) { + Particle.DUST + .builder() + .location(chest.center) + .count(10) + .data(Particle.DustOptions(Color.LIME, 5.0f)) + .receivers(player) + .spawn() } - return false - } - /** - * Get the amount of a specific material in a chest. - * @param inventory The inventory to check. - * @param material The material to count. - * @return The amount of the material in the chest. - */ - private fun doesChestContainCount( - inventory: Inventory, - material: Material, - ): Int = inventory.contents.filter { it?.type == material }.sumOf { it?.amount ?: 0 } + player.playSound(config.soundOnUnload.toSound(), Sound.Emitter.self()) + } /** * Schedules a repeating task for a specific player and automatically cancels it after a set duration. @@ -322,205 +303,10 @@ internal class InvModule : ModuleInterface { return taskId } - /** - * Spawns a particle trail effect visible only to the given player. - * @param startLocation The starting location of the trail. - * @param endLocation The ending location of the trail. - * @param color The colour of the trail. - * @param travelTicks The number of ticks it takes for the trail to travel from start to end. - * @param player The player who will see the particle trail. - * @return A [ParticleBuilder] instance for further configuration if needed. - */ - private fun searchEffect( - startLocation: Location, - endLocation: Location, - color: Color, - travelTicks: Int, - player: Player, - ): ParticleBuilder = - Particle.TRAIL - .builder() - .location(startLocation) - .data(Particle.Trail(endLocation, color, travelTicks)) - .receivers(player) - .spawn() - - /** - * Spawns a critical hit particle effect at the given location, - * visible only to the specified player. - * @param location The central [Location] where the particles will appear. - * @param count The number of particles to spawn. - * @param dustOptions The [Particle.DustOptions] defining the colour and size of the dust particles. - * @param player The [Player] who will see the particle effect. - * @return A [ParticleBuilder] instance for further configuration if needed. - */ - private fun chestEffect( - location: Location, - count: Int, - dustOptions: Particle.DustOptions, - player: Player, - ): ParticleBuilder = - Particle.DUST - .builder() - .location(location) - .count(count) - .data(dustOptions) - .receivers(player) - .spawn() - - /** - * Checks if two ItemStacks have matching enchantments. - * @param first The first ItemStack. - * @param second The second ItemStack. - * @return True if the enchantments match, false otherwise. - */ - private fun hasMatchingEnchantments( - first: ItemStack, - second: ItemStack, - ): Boolean { - if (!config.matchEnchantments && (!config.matchEnchantmentsOnBooks || first.type != Material.ENCHANTED_BOOK)) return true - - val firstMeta = first.itemMeta - val secondMeta = second.itemMeta - - if (firstMeta == null && secondMeta == null) return true - if (firstMeta == null || secondMeta == null) return false - - if (firstMeta is EnchantmentStorageMeta && secondMeta is EnchantmentStorageMeta) { - return firstMeta.storedEnchants == secondMeta.storedEnchants - } - - if (!firstMeta.hasEnchants() && !secondMeta.hasEnchants()) return true - if (firstMeta.hasEnchants() != secondMeta.hasEnchants()) return false - - return firstMeta.enchants == secondMeta.enchants - } - - /** - * Find all blocks in a given radius from a location. - * @param location The location to search from. - * @param radius The radius to search within. - * @return A list of blocks found within the radius. - */ - private fun findBlocksInRadius( - location: Location, - radius: Int, - ): List { - val searchArea = BoundingBox.of(location, radius.toDouble(), radius.toDouble(), radius.toDouble()) - return getChunksInBox(location.world, searchArea) - .asSequence() - .flatMap { it.tileEntities.asSequence() } - .filterIsInstance() - .filter { isRelevantContainer(it, location, radius) } - .map { it.block } - .toList() - } - - /** - * Helper function to determine if a block state is a relevant container. - * @param blockState The block state to check. Must be a Container. - * @param center The centre location of the search area. - * @param radius The radius of the search area. - * @return True if the block state is a relevant container, false otherwise. - */ - private fun isRelevantContainer( - blockState: BlockState, - center: Location, - radius: Int, - ): Boolean { - when { - blockState !is Container || !MaterialRegistry.CONTAINER_TYPES.contains(blockState.type) -> return false - blockState.location.distanceSquared(center) > radius * radius -> return false - blockState.type == Material.CHEST -> { - val blockAbove = blockState.block.getRelative(BlockFace.UP) - if (blockAbove.type.isSolid && blockAbove.type.isOccluding) return false - } - } - return true - } - - /** - * Check if a chest contains an item with matching enchantments. - * @param inventory The inventory to check. - * @param item The item to check for. - * @return True if the chest contains the item, false otherwise. - */ - private fun doesChestContain( - inventory: Inventory, - item: ItemStack, - ): Boolean = - inventory.contents - .asSequence() - .filterNotNull() - .any { it.type == item.type && hasMatchingEnchantments(item, it) } - - /** - * Get all chunks in a bounding box. - * @param world The world to get chunks from. - * @param box The bounding box to get chunks from. - * @return A list of chunks in the bounding box. - */ - private fun getChunksInBox( - world: World, - box: BoundingBox, - ): List { - val minChunkX = Math.floorDiv(box.minX.toInt(), 16) - val maxChunkX = Math.floorDiv(box.maxX.toInt(), 16) - val minChunkZ = Math.floorDiv(box.minZ.toInt(), 16) - val maxChunkZ = Math.floorDiv(box.maxZ.toInt(), 16) - return mutableListOf().apply { - for (x in minChunkX..maxChunkX) { - for (z in minChunkZ..maxChunkZ) { - if (world.isChunkLoaded(x, z)) { - add(world.getChunkAt(x, z)) - } - } - } - } - } - - /** - * Unloads the specified amount of material from the given location. - * @param location The location to unload from. - * @param material The material to unload. - * @param amount The amount of material to unload. - */ - private fun protocolUnload( - location: Location, - material: Material, - amount: Int, - ) { - if (amount == 0) return - unloads.computeIfAbsent(location) { mutableMapOf() }.merge(material, amount, Int::plus) - } - - /** - * Get the centre of a block. - * @return The centre location of the block. - */ - private fun Block.center(): Location { - val loc = location.clone() - val stateChest = state as? Chest ?: return loc.add(0.5, 0.5, 0.5) - val holder = stateChest.inventory.holder as? DoubleChest - if (holder != null) { - val leftLoc = (holder.leftSide as? Chest)?.block?.location - val rightLoc = (holder.rightSide as? Chest)?.block?.location - if (leftLoc != null && rightLoc != null) { - loc.x = (leftLoc.x + rightLoc.x) / 2.0 + 0.5 - loc.y = (leftLoc.y + rightLoc.y) / 2.0 + 0.5 - loc.z = (leftLoc.z + rightLoc.z) / 2.0 + 0.5 - return loc.add(0.5, 0.5, 0.5) - } - } - return loc.add(0.5, 0.5, 0.5) - } - data class Config( override var enabled: Boolean = true, var searchRadius: Int = 25, var unloadRadius: Int = 25, - var matchEnchantments: Boolean = true, - var matchEnchantmentsOnBooks: Boolean = true, var soundOnUnload: SoundData = SoundData( BukkitSound.ENTITY_PLAYER_LEVELUP, diff --git a/src/main/kotlin/org/xodium/vanillaplus/modules/PlayerModule.kt b/src/main/kotlin/org/xodium/vanillaplus/modules/PlayerModule.kt index 0b38b8468..8fbc907a5 100644 --- a/src/main/kotlin/org/xodium/vanillaplus/modules/PlayerModule.kt +++ b/src/main/kotlin/org/xodium/vanillaplus/modules/PlayerModule.kt @@ -77,7 +77,7 @@ internal class PlayerModule( fun on(event: PlayerJoinEvent) { if (!enabled()) return val player = event.player - player.displayName(player.nickname()?.mm()) + player.displayName(player.nickname?.mm()) if (config.i18n.playerJoinMsg.isEmpty()) return event.joinMessage(null) @@ -180,8 +180,8 @@ internal class PlayerModule( player: Player, name: String, ) { - player.nickname(name) - player.displayName(player.nickname()?.mm()) + player.nickname = name + player.displayName(player.nickname?.mm()) tabListModule.updatePlayerDisplayName(player) player.sendActionBar(config.i18n.nicknameUpdated.mm(Placeholder.component("nickname", player.displayName()))) } diff --git a/src/main/kotlin/org/xodium/vanillaplus/modules/ScoreBoardModule.kt b/src/main/kotlin/org/xodium/vanillaplus/modules/ScoreBoardModule.kt index 35906a214..cf4f2aa44 100644 --- a/src/main/kotlin/org/xodium/vanillaplus/modules/ScoreBoardModule.kt +++ b/src/main/kotlin/org/xodium/vanillaplus/modules/ScoreBoardModule.kt @@ -46,7 +46,7 @@ internal class ScoreBoardModule : ModuleInterface { fun on(event: PlayerJoinEvent) { if (!enabled()) return val player = event.player - if (player.scoreboardVisibility() == true) { + if (player.scoreboardVisibility == true) { player.scoreboard = instance.server.scoreboardManager.newScoreboard } else { player.scoreboard = instance.server.scoreboardManager.mainScoreboard @@ -58,12 +58,12 @@ internal class ScoreBoardModule : ModuleInterface { * @param player The player whose scoreboard sidebar should be toggled. */ private fun toggle(player: Player) { - if (player.scoreboardVisibility() == true) { + if (player.scoreboardVisibility == true) { player.scoreboard = instance.server.scoreboardManager.mainScoreboard - player.scoreboardVisibility(false) + player.scoreboardVisibility = false } else { player.scoreboard = instance.server.scoreboardManager.newScoreboard - player.scoreboardVisibility(true) + player.scoreboardVisibility = true } } diff --git a/src/main/kotlin/org/xodium/vanillaplus/pdcs/HorsePDC.kt b/src/main/kotlin/org/xodium/vanillaplus/pdcs/HorsePDC.kt deleted file mode 100644 index d24284d22..000000000 --- a/src/main/kotlin/org/xodium/vanillaplus/pdcs/HorsePDC.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.xodium.vanillaplus.pdcs - -import org.bukkit.NamespacedKey -import org.bukkit.entity.Horse -import org.bukkit.persistence.PersistentDataType -import org.xodium.vanillaplus.VanillaPlus.Companion.instance -import org.xodium.vanillaplus.pdcs.HorsePDC.SOLD_KEY - -/** - * Provides access to horse-specific persistent data including sold status. - * @property SOLD_KEY The namespaced key used for storing sold status data. - */ -internal object HorsePDC { - private val SOLD_KEY = NamespacedKey(instance, "horse_sold") - - /** - * Retrieves the sold status of this horse from its persistent data container. - * @receiver The horse whose sold status to retrieve. - * @return `true` if the horse is marked as sold, `false` if not, or null if the data is not set. - */ - fun Horse.sold(): Boolean = persistentDataContainer.get(SOLD_KEY, PersistentDataType.BOOLEAN) ?: false - - /** - * Sets the sold status of this horse in its persistent data container. - * @receiver The horse whose sold status to modify. - * @param boolean The sold state to set (`true` for sold, `false` for not sold). - */ - fun Horse.sold(boolean: Boolean) = persistentDataContainer.set(SOLD_KEY, PersistentDataType.BOOLEAN, boolean) -} diff --git a/src/main/kotlin/org/xodium/vanillaplus/pdcs/PlayerPDC.kt b/src/main/kotlin/org/xodium/vanillaplus/pdcs/PlayerPDC.kt index e8a802d1b..c4146c5bb 100644 --- a/src/main/kotlin/org/xodium/vanillaplus/pdcs/PlayerPDC.kt +++ b/src/main/kotlin/org/xodium/vanillaplus/pdcs/PlayerPDC.kt @@ -17,38 +17,32 @@ internal object PlayerPDC { private val SCOREBOARD_VISIBILITY_KEY = NamespacedKey(instance, "scoreboard_visibility") /** - * Retrieves the player's nickname from their persistent data container. - * @receiver The player whose nickname to retrieve. - * @return The player's nickname, or null if no nickname is set. + * Gets or sets the player's nickname in their persistent data container. + * @receiver The player whose nickname to access. + * @return The player's nickname, or null if not set. */ - fun Player.nickname(): String? = persistentDataContainer.get(NICKNAME_KEY, PersistentDataType.STRING) - - /** - * Sets or removes the player's nickname in their persistent data container. - * @receiver The player whose nickname to modify. - * @param name The nickname to set, or null/empty to remove the current nickname. - */ - fun Player.nickname(name: String?) { - if (name.isNullOrEmpty()) { - persistentDataContainer.remove(NICKNAME_KEY) - } else { - persistentDataContainer.set(NICKNAME_KEY, PersistentDataType.STRING, name) + var Player.nickname: String? + get() = persistentDataContainer.get(NICKNAME_KEY, PersistentDataType.STRING) + set(value) { + if (value.isNullOrEmpty()) { + persistentDataContainer.remove(NICKNAME_KEY) + } else { + persistentDataContainer.set(NICKNAME_KEY, PersistentDataType.STRING, value) + } } - } /** - * Retrieves the player's scoreboard visibility setting from their persistent data container. - * @receiver The player whose scoreboard visibility to check. - * @return The scoreboard visibility state, or null if not set. + * Gets or sets the player's scoreboard visibility preference in their persistent data container. + * @receiver The player whose scoreboard visibility to access. + * @return True if the scoreboard is visible, false if hidden, or null if not set. */ - fun Player.scoreboardVisibility(): Boolean? = persistentDataContainer.get(SCOREBOARD_VISIBILITY_KEY, PersistentDataType.BOOLEAN) - - /** - * Sets the player's scoreboard visibility in their persistent data container. - * @receiver The player whose scoreboard visibility to modify. - * @param visible The visibility state to set (true for visible, false for hidden). - */ - fun Player.scoreboardVisibility(visible: Boolean) { - persistentDataContainer.set(SCOREBOARD_VISIBILITY_KEY, PersistentDataType.BOOLEAN, visible) - } + var Player.scoreboardVisibility: Boolean? + get() = persistentDataContainer.get(SCOREBOARD_VISIBILITY_KEY, PersistentDataType.BOOLEAN) + set(value) { + if (value == null) { + persistentDataContainer.remove(SCOREBOARD_VISIBILITY_KEY) + } else { + persistentDataContainer.set(SCOREBOARD_VISIBILITY_KEY, PersistentDataType.BOOLEAN, value) + } + } } diff --git a/src/main/kotlin/org/xodium/vanillaplus/utils/BlockUtils.kt b/src/main/kotlin/org/xodium/vanillaplus/utils/BlockUtils.kt new file mode 100644 index 000000000..cf1ec15b2 --- /dev/null +++ b/src/main/kotlin/org/xodium/vanillaplus/utils/BlockUtils.kt @@ -0,0 +1,33 @@ +@file:Suppress("unused") + +package org.xodium.vanillaplus.utils + +import org.bukkit.Location +import org.bukkit.block.Block +import org.bukkit.block.Chest +import org.bukkit.block.DoubleChest + +/** Block utilities. */ +internal object BlockUtils { + /** + * Get the centre of a block, handling double chests properly. + * @return The centre location of the block. + */ + val Block.center: Location + get() { + val baseAddition = Location(location.world, location.x + 0.5, location.y + 0.5, location.z + 0.5) + val chestState = state as? Chest ?: return baseAddition + val holder = chestState.inventory.holder as? DoubleChest ?: return baseAddition + val leftBlock = (holder.leftSide as? Chest)?.block + val rightBlock = (holder.rightSide as? Chest)?.block + + if (leftBlock == null || rightBlock == null || leftBlock.world !== rightBlock.world) return baseAddition + + val world = leftBlock.world + val cx = (leftBlock.x + rightBlock.x) / 2.0 + 0.5 + val cy = (leftBlock.y + rightBlock.y) / 2.0 + 0.5 + val cz = (leftBlock.z + rightBlock.z) / 2.0 + 0.5 + + return Location(world, cx, cy, cz) + } +} diff --git a/src/main/kotlin/org/xodium/vanillaplus/utils/ChunkUtils.kt b/src/main/kotlin/org/xodium/vanillaplus/utils/ChunkUtils.kt new file mode 100644 index 000000000..69b25ae72 --- /dev/null +++ b/src/main/kotlin/org/xodium/vanillaplus/utils/ChunkUtils.kt @@ -0,0 +1,113 @@ +@file:Suppress("ktlint:standard:no-wildcard-imports") + +package org.xodium.vanillaplus.utils + +import org.bukkit.Location +import org.bukkit.Material +import org.bukkit.block.Block +import org.bukkit.block.Container +import org.bukkit.block.DoubleChest +import org.bukkit.inventory.InventoryHolder +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** Chunk utilities. */ +internal object ChunkUtils { + private val cache = ConcurrentHashMap() + + private data class ChunkCoord( + val world: UUID, + val x: Int, + val z: Int, + ) + + /** + * Find all container blocks in a given radius from a location. + * @param location The location to search from. + * @param radius The radius to search within. + * @param containerTypes Set of valid container materials. + * @param containerFilter An additional filter for containers. + * @return List of container blocks found within the radius. + */ + fun findContainersInRadius( + location: Location, + radius: Int, + containerTypes: Set, + containerFilter: (Block) -> Boolean = { true }, + ): List { + val world = location.world + val centerX = location.blockX + val centerZ = location.blockZ + val radiusSquared = radius * radius + // Calculate chunk bounds using bit shifting for performance + val minChunkX = (centerX - radius) shr 4 + val maxChunkX = (centerX + radius) shr 4 + val minChunkZ = (centerZ - radius) shr 4 + val maxChunkZ = (centerZ + radius) shr 4 + val containers = mutableListOf() + + // Iterate through chunks first (more efficient) + for (chunkX in minChunkX..maxChunkX) { + for (chunkZ in minChunkZ..maxChunkZ) { + val chunkCoord = ChunkCoord(world.uid, chunkX, chunkZ) + + // Check if chunk is loaded using cache + if (!cache.computeIfAbsent(chunkCoord) { + world.isChunkLoaded(chunkX, chunkZ) + } + ) { + continue + } + + val chunk = world.getChunkAt(chunkX, chunkZ) + + // Process tile entities in this chunk + for (tileEntity in chunk.tileEntities) { + if (tileEntity !is Container) continue + + val block = tileEntity.block + + // Fast material check + if (!containerTypes.contains(block.type)) continue + + // Fast distance check using integer math + val dx = block.x - centerX + val dz = block.z - centerZ + if (dx * dx + dz * dz > radiusSquared) continue + + // Apply additional filters + if (containerFilter(block)) { + containers.add(block) + } + } + } + } + + // Clear cache periodically to prevent memory leaks + if (cache.size > 1000) cache.clear() + + return containers + } + + /** + * Filter out double chest duplicates and sort by distance. + * @param containers List of container blocks. + * @param centerLocation The center location for distance calculation. + * @return Filtered and sorted list of containers. + */ + fun filterAndSortContainers( + containers: List, + centerLocation: Location, + ): List { + val seenDoubleChests = mutableSetOf() + val filteredContainers = + containers.filter { block -> + val inventory = (block.state as Container).inventory + val holder = inventory.holder + if (holder is DoubleChest) seenDoubleChests.add(holder.leftSide) + true + } + + return filteredContainers.sortedBy { it.location.distanceSquared(centerLocation) } + } +} diff --git a/src/main/kotlin/org/xodium/vanillaplus/utils/ExtUtils.kt b/src/main/kotlin/org/xodium/vanillaplus/utils/ExtUtils.kt index 53eeedfe5..dbf1b3406 100644 --- a/src/main/kotlin/org/xodium/vanillaplus/utils/ExtUtils.kt +++ b/src/main/kotlin/org/xodium/vanillaplus/utils/ExtUtils.kt @@ -36,9 +36,25 @@ internal object ExtUtils { private const val RED_SHIFT = 16 private const val GREEN_SHIFT = 8 + /** The standardized prefix for [VanillaPlus] messages. */ val VanillaPlus.prefix: String get() = "${"[".mangoFmt(true)}${this::class.simpleName.toString().fireFmt()}${"]".mangoFmt()}" + /** Serializes a [Component] into plaintext. */ + val Component.pt: String get() = PlainTextComponentSerializer.plainText().serialize(this) + + /** + * Converts a CamelCase string to snake case. + * @return the snake case version of the string. + */ + val String.snakeCase: String get() = replace(Regex("([a-z])([A-Z])"), "$1_$2").lowercase() + + /** + * Generates a configuration key for a module. + * @return The generated configuration key. + */ + val ModuleInterface<*>.key: String get() = this::class.simpleName.toString() + /** * Deserializes a [MiniMessage] [String] into a [Component]. * @param resolvers Optional tag resolvers for custom tags. @@ -59,23 +75,38 @@ internal object ExtUtils { @JvmName("mmStringIterable") fun Iterable.mm(vararg resolvers: TagResolver): List = map { it.mm(*resolvers) } - /** Serializes a [Component] into a String. */ - fun Component.mm(): String = MM.serialize(this) - - /** Serializes a [Component] into plaintext. */ - fun Component.pt(): String = PlainTextComponentSerializer.plainText().serialize(this) - /** * Performs a command from a [String]. - * @param hover Optional hover text for the command. + * @param cmd The command to perform. + * @param hover Optional hover text for the command. Defaults to "Click me!". * @return The formatted [String] with the command. */ - fun String.clickRunCmd(hover: String? = null): String = - if (hover != null) { - "$this" - } else { - "$this" - } + fun String.clickRunCmd( + cmd: String, + hover: String? = "Click me!".mangoFmt(), + ): String = "$this" + + /** + * Suggests a command from a [String]. + * @param cmd The command to suggest. + * @param hover Optional hover text for the command. Defaults to "Click me!". + * @return The formatted [String] with the suggested command. + */ + fun String.clickSuggestCmd( + cmd: String, + hover: String? = "Click me!".mangoFmt(), + ): String = "$this" + + /** + * Opens a URL from a [String]. + * @param url The URL to open. + * @param hover Optional hover text for the URL. Defaults to "Click me!". + * @return The formatted [String] with the URL. + */ + fun String.clickOpenUrl( + url: String, + hover: String? = "Click me!".mangoFmt(), + ): String = "$this" /** * A helper function to wrap command execution with standardized error handling. @@ -83,7 +114,7 @@ internal object ExtUtils { * @return Command.SINGLE_SUCCESS after execution. */ fun CommandContext.tryCatch(action: (CommandSourceStack) -> Unit): Int { - runCatching { action(this.source) } + runCatching { action(source) } .onFailure { e -> instance.logger.severe( """ @@ -91,7 +122,7 @@ internal object ExtUtils { ${e.stackTraceToString()} """.trimIndent(), ) - (this.source.sender as? Player)?.sendMessage( + (source.sender as? Player)?.sendMessage( "${instance.prefix} An error has occurred. Check server logs for details.".mm(), ) } @@ -143,16 +174,4 @@ internal object ExtUtils { } return builder.toString() } - - /** - * Converts a CamelCase string to snake case. - * @return the snake case version of the string. - */ - fun String.toSnakeCase(): String = this.replace(Regex("([a-z])([A-Z])"), "$1_$2").lowercase() - - /** - * Generates a configuration key for a module. - * @return The generated configuration key. - */ - fun ModuleInterface<*>.key(): String = this::class.simpleName.toString() } diff --git a/src/main/kotlin/org/xodium/vanillaplus/utils/InvUtils.kt b/src/main/kotlin/org/xodium/vanillaplus/utils/InvUtils.kt new file mode 100644 index 000000000..3a910d592 --- /dev/null +++ b/src/main/kotlin/org/xodium/vanillaplus/utils/InvUtils.kt @@ -0,0 +1,76 @@ +package org.xodium.vanillaplus.utils + +import org.bukkit.Tag +import org.bukkit.block.ShulkerBox +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack + +/** Inventory utilities. */ +internal object InvUtils { + /** + * Check if transferring an item would be valid (not putting shulker in shulker, etc.) + * @param item The item to transfer. + * @param destination The destination inventory. + * @return True if the transfer is valid, false otherwise. + */ + fun isValidTransfer( + item: ItemStack, + destination: Inventory, + ): Boolean = !(Tag.SHULKER_BOXES.isTagged(item.type) && destination.holder is ShulkerBox) + + /** + * Transfer items from source to destination inventory. + * @param source The source inventory. + * @param destination The destination inventory. + * @param startSlot The starting slot in source inventory. + * @param endSlot The ending slot in source inventory. + * @param onlyMatching If true, only transfer items that already exist in the destination. + * @param enchantmentChecker Function to check if enchantments match. + * @return Pair + */ + fun transferItems( + source: Inventory, + destination: Inventory, + startSlot: Int = 9, + endSlot: Int = 35, + onlyMatching: Boolean = false, + enchantmentChecker: (ItemStack, ItemStack) -> Boolean = { _, _ -> true }, + ): Boolean { + var moved = false + + for (i in startSlot..endSlot) { + val item = source.getItem(i) ?: continue + + if (!isValidTransfer(item, destination)) continue + if (onlyMatching && !containsMatchingItem(destination, item, enchantmentChecker)) continue + + val leftovers = destination.addItem(item) + val movedAmount = item.amount - leftovers.values.sumOf { it.amount } + + if (movedAmount > 0) { + moved = true + source.clear(i) + leftovers.values.firstOrNull()?.let { source.setItem(i, it) } + } + } + + return moved + } + + /** + * Check if inventory contains an item with matching type and enchantments. + * @param inventory The inventory to check. + * @param item The item to match. + * @param enchantmentChecker Function to check enchantment compatibility. + * @return True if a matching item is found. + */ + private fun containsMatchingItem( + inventory: Inventory, + item: ItemStack, + enchantmentChecker: (ItemStack, ItemStack) -> Boolean, + ): Boolean = + inventory.contents + .asSequence() + .filterNotNull() + .any { it.type == item.type && enchantmentChecker(item, it) } +} diff --git a/src/main/kotlin/org/xodium/vanillaplus/utils/ItemStackUtils.kt b/src/main/kotlin/org/xodium/vanillaplus/utils/ItemStackUtils.kt new file mode 100644 index 000000000..8dad251c1 --- /dev/null +++ b/src/main/kotlin/org/xodium/vanillaplus/utils/ItemStackUtils.kt @@ -0,0 +1,23 @@ +package org.xodium.vanillaplus.utils + +import io.papermc.paper.datacomponent.DataComponentTypes +import org.bukkit.Material +import org.bukkit.inventory.ItemStack + +/** ItemStack utilities. */ +internal object ItemStackUtils { + /** + * Checks if two ItemStacks have matching enchantments. + * @param first The first ItemStack. + * @param second The second ItemStack. + * @return True if the enchantments match, false otherwise. + */ + @Suppress("UnstableApiUsage") + fun hasMatchingEnchantments( + first: ItemStack, + second: ItemStack, + ): Boolean { + if (first.type != Material.ENCHANTED_BOOK && (first.enchantments.isEmpty() && second.enchantments.isEmpty())) return true + return first.getData(DataComponentTypes.ENCHANTMENTS) == second.getData(DataComponentTypes.ENCHANTMENTS) + } +}