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)
+ }
+}