diff --git a/gradle.properties b/gradle.properties index a4c71d5d8..fd615bd04 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,8 +5,8 @@ kapt.use.k2=true org.gradle.jvmargs=-Xmx8G org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled javaVersion=25 -mcVersion=26.1.1 +mcVersion=26.1.2 group=dev.slne.surf.api -version=3.9.5 +version=3.10.0 relocationPrefix=dev.slne.surf.api.libs snapshot=false diff --git a/surf-api-paper/surf-api-paper-plugin-test/build.gradle.kts b/surf-api-paper/surf-api-paper-plugin-test/build.gradle.kts index 2abfe8ad9..d78b8ad6d 100644 --- a/surf-api-paper/surf-api-paper-plugin-test/build.gradle.kts +++ b/surf-api-paper/surf-api-paper-plugin-test/build.gradle.kts @@ -1,4 +1,5 @@ import net.minecrell.pluginyml.paper.PaperPluginDescription +import xyz.jpenilla.runpaper.task.RunServer plugins { `core-convention` @@ -24,6 +25,7 @@ paper { description = "Test plugin for Surf API for Paper" author = "twisti" apiVersion = "1.21" + foliaSupported = true serverDependencies { register("CommandAPI") { @@ -40,21 +42,36 @@ paper { } } -tasks { - runServer { - dependsOn(":surf-api-paper:surf-api-paper-server:shadowJar") - pluginJars.from(project(":surf-api-paper:surf-api-paper-server").tasks.shadowJar) +fun RunServer.configure(folia: Boolean) { + dependsOn(":surf-api-paper:surf-api-paper-server:shadowJar") + pluginJars.from(project(":surf-api-paper:surf-api-paper-server").tasks.shadowJar) + + minecraftVersion(findProperty("mcVersion") as String) - minecraftVersion(findProperty("mcVersion") as String) + downloadPlugins { + hangar("CommandAPI", libs.versions.commandapi.get()) + modrinth("packetevents", libs.versions.packetevents.plugin.get() + "+spigot") - downloadPlugins { - hangar("CommandAPI", libs.versions.commandapi.get()) - modrinth("packetevents", libs.versions.packetevents.plugin.get() + "+spigot") + if (!folia) { modrinth("luckperms", libs.versions.luckpermsplugin.bukkit.get()) + } else { + url("https://ci.lucko.me/job/LuckPerms-Folia/11/artifact/bukkit/loader/build/libs/LuckPerms-Bukkit-5.5.29.jar") } } } +runPaper { + folia.registerTask { + configure(true) + } +} + +tasks { + runServer { + configure(false) + } +} + tasks { shadowJar { val relocationPrefix: String by project diff --git a/surf-api-paper/surf-api-paper-plugin-test/src/main/java/dev/slne/surf/api/paper/test/command/SurfApiTestCommand.java b/surf-api-paper/surf-api-paper-plugin-test/src/main/java/dev/slne/surf/api/paper/test/command/SurfApiTestCommand.java index 3e06de487..e9e3fdd89 100644 --- a/surf-api-paper/surf-api-paper-plugin-test/src/main/java/dev/slne/surf/api/paper/test/command/SurfApiTestCommand.java +++ b/surf-api-paper/surf-api-paper-plugin-test/src/main/java/dev/slne/surf/api/paper/test/command/SurfApiTestCommand.java @@ -1,6 +1,6 @@ package dev.slne.surf.api.paper.test.command; -import dev.jorel.commandapi.CommandAPICommand; +import dev.jorel.commandapi.*; import dev.slne.surf.api.paper.test.command.subcommands.*; import dev.slne.surf.surfapi.bukkit.test.command.subcommands.*; @@ -30,7 +30,8 @@ public SurfApiTestCommand() { new SurfEventHandlerTest("eventhandler"), new ShowItemCommand("showitem"), new SortInvCommand("sortInv"), - new SignedMessageArgumentTest("signedmessage") + new SignedMessageArgumentTest("signedmessage"), + new BlockPdcContainerTest("blockpdc") ); } } diff --git a/surf-api-paper/surf-api-paper-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/command/subcommands/BlockPdcContainerTest.kt b/surf-api-paper/surf-api-paper-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/command/subcommands/BlockPdcContainerTest.kt new file mode 100644 index 000000000..cbdfd5f14 --- /dev/null +++ b/surf-api-paper/surf-api-paper-plugin-test/src/main/kotlin/dev/slne/surf/surfapi/bukkit/test/command/subcommands/BlockPdcContainerTest.kt @@ -0,0 +1,228 @@ +package dev.slne.surf.surfapi.bukkit.test.command.subcommands + +import com.github.shynixn.mccoroutine.folia.regionDispatcher +import dev.jorel.commandapi.CommandAPICommand +import dev.jorel.commandapi.arguments.LocationType +import dev.jorel.commandapi.kotlindsl.* +import dev.slne.surf.api.paper.command.executors.playerExecutorSuspend +import dev.slne.surf.api.paper.pdc.block.pdc +import dev.slne.surf.api.paper.util.chunkX +import dev.slne.surf.api.paper.util.chunkZ +import dev.slne.surf.api.paper.util.doInChunkAsync +import dev.slne.surf.surfapi.bukkit.test.plugin +import kotlinx.coroutines.withContext +import org.bukkit.Location +import org.bukkit.NamespacedKey +import org.bukkit.entity.Player +import org.bukkit.persistence.PersistentDataType +import kotlin.math.abs +import kotlin.math.max + +class BlockPdcContainerTest(name: String) : CommandAPICommand(name) { + init { + setCommand() + getCommand() + listCommand() + clearCommand() + copyNearCommand() + copyFarCommand() + } + + private fun setCommand() = subcommand("set") { + locationArgument("location", LocationType.BLOCK_POSITION) + textArgument("key") + greedyStringArgument("value") + + playerExecutorSuspend { sender, args -> + val location: Location by args + val key: String by args + val value: String by args + + val world = location.world ?: run { + sender.sendMessage("Location has no world.") + return@playerExecutorSuspend + } + + val nsKey = NamespacedKey(plugin, key) + + world.doInChunkAsync(location.chunkX, location.chunkZ) { chunk -> + val block = chunk.getBlock(location.blockX and 15, location.blockY, location.blockZ and 15) + block.pdc().set(nsKey, PersistentDataType.STRING, value) + } + + sender.sendMessage("Set '$key' = '$value' on block at (${location.blockX}, ${location.blockY}, ${location.blockZ}).") + } + } + + private fun getCommand() = subcommand("get") { + locationArgument("location", LocationType.BLOCK_POSITION) + textArgument("key") + + playerExecutorSuspend { sender, args -> + val location: Location by args + val key: String by args + + val world = location.world ?: run { + sender.sendMessage("Location has no world.") + return@playerExecutorSuspend + } + + val nsKey = NamespacedKey(plugin, key) + + val result = world.doInChunkAsync(location.chunkX, location.chunkZ) { chunk -> + val block = chunk.getBlock(location.blockX and 15, location.blockY, location.blockZ and 15) + block.pdc().get(nsKey, PersistentDataType.STRING) + } + + if (result != null) { + sender.sendMessage("Block PDC [$key] = '$result' at (${location.blockX}, ${location.blockY}, ${location.blockZ}).") + } else { + sender.sendMessage("No value for key '$key' on block at (${location.blockX}, ${location.blockY}, ${location.blockZ}).") + } + } + } + + private fun listCommand() = subcommand("list") { + locationArgument("location", LocationType.BLOCK_POSITION) + + playerExecutorSuspend { sender, args -> + val location: Location by args + + val world = location.world ?: run { + sender.sendMessage("Location has no world.") + return@playerExecutorSuspend + } + + val keys = world.doInChunkAsync(location.chunkX, location.chunkZ) { chunk -> + val block = chunk.getBlock(location.blockX and 15, location.blockY, location.blockZ and 15) + block.pdc().keys.map { it.toString() } + } + + if (keys.isEmpty()) { + sender.sendMessage("Block PDC at (${location.blockX}, ${location.blockY}, ${location.blockZ}) is empty.") + } else { + sender.sendMessage("Block PDC keys at (${location.blockX}, ${location.blockY}, ${location.blockZ}) [${keys.size}]:") + keys.forEach { sender.sendMessage(" - $it") } + } + } + } + + private fun clearCommand() = subcommand("clear") { + locationArgument("location", LocationType.BLOCK_POSITION) + + playerExecutorSuspend { sender, args -> + val location: Location by args + + val world = location.world ?: run { + sender.sendMessage("Location has no world.") + return@playerExecutorSuspend + } + + world.doInChunkAsync(location.chunkX, location.chunkZ) { chunk -> + val block = chunk.getBlock(location.blockX and 15, location.blockY, location.blockZ and 15) + block.pdc().clear() + } + + sender.sendMessage("Cleared block PDC at (${location.blockX}, ${location.blockY}, ${location.blockZ}).") + } + } + + private fun copyNearCommand() = subcommand("copy-near") { + locationArgument("source", LocationType.BLOCK_POSITION) + locationArgument("target", LocationType.BLOCK_POSITION) + + playerExecutorSuspend { sender, args -> + val source: Location by args + val target: Location by args + + if (!isSameWorld(sender, source, target)) { + return@playerExecutorSuspend + } + + val chunkDistance = chunkDistance(source, target) + if (chunkDistance > 1) { + sender.sendMessage("copy-near expects source and target in the same region (chunk distance <= 1), got $chunkDistance.") + return@playerExecutorSuspend + } + + runCopyTest(sender, source, target, "near") + } + } + + private fun copyFarCommand() = subcommand("copy-far") { + locationArgument("source", LocationType.BLOCK_POSITION) + locationArgument("target", LocationType.BLOCK_POSITION) + + playerExecutorSuspend { sender, args -> + val source: Location by args + val target: Location by args + + if (!isSameWorld(sender, source, target)) { + return@playerExecutorSuspend + } + + val chunkDistance = chunkDistance(source, target) + if (chunkDistance < FAR_MIN_CHUNK_DISTANCE) { + sender.sendMessage("copy-far expects blocks to be far apart (chunk distance >= $FAR_MIN_CHUNK_DISTANCE), got $chunkDistance.") + return@playerExecutorSuspend + } + + runCopyTest(sender, source, target, "far") + } + } + + private fun isSameWorld(sender: Player, source: Location, target: Location): Boolean { + if (source.world == null || target.world == null) { + sender.sendMessage("Both source and target must include a world.") + return false + } + + if (source.world != target.world) { + sender.sendMessage("Source and target must be in the same world.") + return false + } + + return true + } + + private suspend fun runCopyTest(sender: Player, source: Location, target: Location, label: String) { + val value = "$label-copy-${System.currentTimeMillis()}" + + val result = runCatching { + val sourcePdc = withContext(plugin.regionDispatcher(source)) { + val sourceBlock = source.block + val sourcePdc = sourceBlock.pdc() + sourcePdc.set(TEST_KEY, PersistentDataType.STRING, value) + sourcePdc + } + + withContext(plugin.regionDispatcher(target)) { + val targetBlock = target.block + sourcePdc.copyTo(targetBlock) + targetBlock.pdc().get(TEST_KEY, PersistentDataType.STRING) + } + } + + result.onSuccess { copiedValue -> + if (copiedValue == value) { + sender.sendMessage("Block PDC copy test [$label] passed. Value '$copiedValue' was copied successfully.") + } else { + sender.sendMessage("Block PDC copy test [$label] failed. Expected '$value', got '${copiedValue ?: "null"}'.") + } + }.onFailure { error -> + sender.sendMessage("Block PDC copy test [$label] failed with exception: ${error::class.simpleName}: ${error.message}") + } + } + + private fun chunkDistance(source: Location, target: Location): Int { + val dx = abs(source.chunkX - target.chunkX) + val dz = abs(source.chunkZ - target.chunkZ) + return max(dx, dz) + } + + companion object { + private const val FAR_MIN_CHUNK_DISTANCE = 32 + private val TEST_KEY = NamespacedKey(plugin, "block-pdc-copy-test") + } +} + diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/BlockDataListener.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/BlockDataListener.kt index 68560eaf3..40c596133 100644 --- a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/BlockDataListener.kt +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/BlockDataListener.kt @@ -22,9 +22,9 @@ */ package dev.slne.surf.api.paper.server.impl.pdc.block +import dev.slne.surf.api.paper.pdc.block.CustomBlockPersistentDataContainer import dev.slne.surf.api.paper.pdc.block.pdc import it.unimi.dsi.fastutil.objects.Object2ObjectLinkedOpenHashMap -import org.bukkit.NamespacedKey import org.bukkit.block.Block import org.bukkit.block.PistonMoveReaction import org.bukkit.block.data.type.Fire @@ -35,8 +35,6 @@ import org.bukkit.event.block.* import org.bukkit.event.entity.EntityChangeBlockEvent import org.bukkit.event.entity.EntityExplodeEvent import org.bukkit.event.world.StructureGrowEvent -import org.bukkit.persistence.PersistentDataContainer -import org.bukkit.persistence.PersistentDataType object BlockDataListener : Listener { private fun remove(event: BlockEvent) { @@ -164,60 +162,28 @@ object BlockDataListener : Listener { private fun handlePiston( blocks: List, event: BlockPistonEvent - ) { // TODO: Use a more efficient data structure - val map = Object2ObjectLinkedOpenHashMap>() + ) { + val map = Object2ObjectLinkedOpenHashMap() val direction = event.direction for (block in blocks) { + if (!BlockPdcManager.hasCustomData(block)) continue val pdc = block.pdc() if (pdc.isEmpty) continue - val reaction = block.pistonMoveReaction if (reaction == PistonMoveReaction.BREAK) { removeFromBlock(block) continue } - val snapshot = pdc.keys.associateWith { key -> - when { - pdc.has(key, PersistentDataType.BYTE_ARRAY) -> - pdc.get(key, PersistentDataType.BYTE_ARRAY) - - pdc.has(key, PersistentDataType.STRING) -> - pdc.get(key, PersistentDataType.STRING) - - pdc.has(key, PersistentDataType.BOOLEAN) -> - pdc.get(key, PersistentDataType.BOOLEAN) - - pdc.has(key, PersistentDataType.INTEGER) -> - pdc.get(key, PersistentDataType.INTEGER) - - pdc.has(key, PersistentDataType.TAG_CONTAINER) -> - pdc.get(key, PersistentDataType.TAG_CONTAINER) - - else -> null - } - }.filterValues { it != null } as Map - val destination = block.getRelative(direction) - map[destination] = snapshot + map[destination] = pdc } - map.entries.toList().asReversed().forEach { (block, data) -> - val target = block.pdc() - - target.clear() - - data.forEach { (key, value) -> - when (value) { - is ByteArray -> target.set(key, PersistentDataType.BYTE_ARRAY, value) - is String -> target.set(key, PersistentDataType.STRING, value) - is Int -> target.set(key, PersistentDataType.INTEGER, value) - is Boolean -> target.set(key, PersistentDataType.BOOLEAN, value) - is PersistentDataContainer -> - target.set(key, PersistentDataType.TAG_CONTAINER, value) - } - } + for ((block, pdc) in map.object2ObjectEntrySet().toList().reversed()) { + block.pdc().clear() + pdc.copyTo(block) + pdc.clear() } } } \ No newline at end of file diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/BlockPdcProviderImpl.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/BlockPdcProviderImpl.kt index 5f638b660..8810990ba 100644 --- a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/BlockPdcProviderImpl.kt +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/BlockPdcProviderImpl.kt @@ -3,11 +3,13 @@ package dev.slne.surf.api.paper.server.impl.pdc.block import com.google.auto.service.AutoService import dev.slne.surf.api.paper.pdc.block.BlockPdcProvider import dev.slne.surf.api.paper.pdc.block.CustomBlockPersistentDataContainer +import dev.slne.surf.api.paper.region.TickThreadGuard import org.bukkit.block.Block @AutoService(BlockPdcProvider::class) class BlockPdcProviderImpl : BlockPdcProvider { override fun getPdc(block: Block): CustomBlockPersistentDataContainer { + TickThreadGuard.ensureTickThread(block.world, block.location, "Cannot get PDC of block off tick thread!") return CustomBlockData(block) } } \ No newline at end of file diff --git a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/CustomBlockData.kt b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/CustomBlockData.kt index d67be4268..2fdefd63a 100644 --- a/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/CustomBlockData.kt +++ b/surf-api-paper/surf-api-paper-server/src/main/kotlin/dev/slne/surf/api/paper/server/impl/pdc/block/CustomBlockData.kt @@ -31,9 +31,12 @@ import org.bukkit.block.Block import org.bukkit.persistence.PersistentDataAdapterContext import org.bukkit.persistence.PersistentDataContainer import org.bukkit.persistence.PersistentDataType +import java.lang.ref.WeakReference class CustomBlockData(val block: Block) : CustomBlockPersistentDataContainer { - override val chunk: Chunk = block.chunk + private val chunkRef = WeakReference(block.chunk) + override val chunk: Chunk get() = chunkRef.get() ?: error("Chunk reference is no longer valid!") + private val key = getKey(block) private val pdc = getPersistentDataContainer() @@ -59,7 +62,9 @@ class CustomBlockData(val block: Block) : CustomBlockPersistentDataContainer { } override fun copyTo(block: Block) { - copyTo(block.pdc(), true) + val other = block.pdc() as CustomBlockData + copyTo(other.pdc, true) + other.save() } override fun

set( @@ -132,7 +137,11 @@ class CustomBlockData(val block: Block) : CustomBlockPersistentDataContainer { companion object { fun getKey(block: Block): NamespacedKey { - return namespacedKey("x${block.x and 15}y${block.y}z${block.z and 15}") + return getKey(block.x, block.y, block.z) + } + + fun getKey(blockX: Int, blockY: Int, blockZ: Int): NamespacedKey { + return namespacedKey("x${blockX and 15}y${blockY}z${blockZ and 15}") } } } \ No newline at end of file diff --git a/surf-api-paper/surf-api-paper/api/surf-api-paper.api b/surf-api-paper/surf-api-paper/api/surf-api-paper.api index cedd2dbab..8f6b9bf0f 100644 --- a/surf-api-paper/surf-api-paper/api/surf-api-paper.api +++ b/surf-api-paper/surf-api-paper/api/surf-api-paper.api @@ -2570,6 +2570,8 @@ public final class dev/slne/surf/api/paper/time/TimeSkipResult : java/lang/Enum public final class dev/slne/surf/api/paper/util/UtilBukkit { public static final fun computeHighestYBlock (Ljava/util/Collection;Lorg/bukkit/World;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun distanceSqt (Lorg/bukkit/Location;Lorg/bukkit/Location;)D + public static final fun doInChunkAsync (Lorg/bukkit/World;IILkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun doInChunkAsync (Lorg/bukkit/World;Lorg/bukkit/Location;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun forEachPlayer (Lkotlin/jvm/functions/Function1;)V public static final fun forEachPlayerInRegion (Lcom/github/shynixn/mccoroutine/folia/SuspendingPlugin;Lkotlin/jvm/functions/Function2;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun forEachPlayerInRegion (Lkotlin/jvm/functions/Function2;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/util/bukkit-util.kt b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/util/bukkit-util.kt index a1d738cc8..b698a6fa6 100644 --- a/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/util/bukkit-util.kt +++ b/surf-api-paper/surf-api-paper/src/main/kotlin/dev/slne/surf/api/paper/util/bukkit-util.kt @@ -28,6 +28,7 @@ import org.spongepowered.math.vector.Vector3d import org.spongepowered.math.vector.Vector3i import java.util.* import java.util.concurrent.ConcurrentHashMap +import java.util.function.Consumer /** * Creates a [NamespacedKey] using the calling plugin and the given name. @@ -139,6 +140,12 @@ val Location.chunkZ get() = blockZ shr 4 */ val Location.chunkKey get() = Chunk.getChunkKey(this) +/** + * Converts this [Location] to a Sponge [Vector3d]. + * + * @receiver The source location. + * @return A [Vector3d] with the same x, y, and z coordinates. + */ fun Location.toVector3d(): Vector3d { return Vector3d( this.x, @@ -155,6 +162,12 @@ fun Location.toVector3d(): Vector3d { */ fun Iterable.toPlayers() = mapNotNull { Bukkit.getPlayer(it) } +/** + * Converts an iterable of UUIDs to a list of [OfflinePlayer] instances. + * + * @receiver The collection of UUIDs. + * @return A list of matching [OfflinePlayer] instances. + */ fun Iterable.toOfflinePlayers() = mapNotNull { Bukkit.getOfflinePlayer(it) } /** @@ -165,6 +178,12 @@ fun Iterable.toOfflinePlayers() = mapNotNull { Bukkit.getOfflinePlayer(it) */ fun Sequence.toPlayers() = mapNotNull { Bukkit.getPlayer(it) } +/** + * Converts a sequence of UUIDs to a sequence of [OfflinePlayer] instances. + * + * @receiver The sequence of UUIDs. + * @return A sequence of matching [OfflinePlayer] instances. + */ fun Sequence.toOfflinePlayers() = mapNotNull { Bukkit.getOfflinePlayer(it) } /** @@ -178,6 +197,15 @@ fun Player.isChunkVisible(location: Location): Boolean { return this.world == location.world && this.isChunkSent(Chunk.getChunkKey(location)) } +/** + * Checks whether the player can currently see the specified chunk in a world. + * + * @receiver The player to check visibility for. + * @param world The world the chunk belongs to. + * @param chunkX The chunk x-coordinate. + * @param chunkZ The chunk z-coordinate. + * @return `true` if the chunk is sent to the player in the same world, otherwise `false`. + */ fun Player.isChunkVisible(world: World, chunkX: Int, chunkZ: Int): Boolean { if (this.world != world) return false return this.isChunkSent(Chunk.getChunkKey(chunkX, chunkZ)) @@ -193,14 +221,33 @@ private fun getCallingSuspendingPlugin() = getCallingPlugin(2) as? SuspendingPlu ?: error("Cannot determine plugin") +/** + * Extracts the x-coordinate from a packed chunk key. + * + * @param key The packed chunk key from [Chunk.getChunkKey]. + * @return The unpacked chunk x-coordinate. + */ fun getXFromChunkKey(key: Long): Int { return (key and 0xFFFF_FFFFL).toInt() } +/** + * Extracts the z-coordinate from a packed chunk key. + * + * @param key The packed chunk key from [Chunk.getChunkKey]. + * @return The unpacked chunk z-coordinate. + */ fun getZFromChunkKey(key: Long): Int { return (key ushr 32).toInt() } +/** + * Resolves the highest block Y value for absolute block coordinates within this snapshot. + * + * @param blockX The absolute block x-coordinate. + * @param blockZ The absolute block z-coordinate. + * @return The highest Y value at the given block coordinates. + */ fun ChunkSnapshot.getHighestBlockYAtBlockCoordinates( blockX: Int, blockZ: Int, @@ -208,6 +255,15 @@ fun ChunkSnapshot.getHighestBlockYAtBlockCoordinates( return getHighestBlockYAt(blockX and 15, blockZ and 15) } +/** + * Computes the highest solid block Y value for each input x/z point. + * + * Chunks are loaded asynchronously and sampled via chunk snapshots to avoid repeated world access. + * + * @receiver The collection of x/y/z vectors; only x and z are used as input coordinates. + * @param world The world to query. + * @return A list of vectors containing the original x/z and the computed highest y value. + */ suspend fun Collection.computeHighestYBlock(world: World): ObjectList { val chunkKeys = LongOpenHashSet(size / 4 + 1) for (point in this) { @@ -251,6 +307,13 @@ suspend fun Collection.computeHighestYBlock(world: World): ObjectList< return result } +/** + * Asynchronously resolves a block at the given [BlockPosition], ensuring the target chunk is loaded. + * + * @receiver The world to resolve the block in. + * @param pos The absolute block position. + * @return The resolved [Block] instance. + */ suspend fun World.getBlockAtAsync(pos: BlockPosition): Block { val chunkX = pos.blockX() shr 4 val chunkZ = pos.blockZ() shr 4 @@ -266,17 +329,81 @@ suspend fun World.getBlockAtAsync(pos: BlockPosition): Block { } } +/** + * Runs an action once the chunk at the given [Location] is loaded asynchronously. + * The [action] is executed on the owning tick thread of the target chunk. + * + * @receiver The world that owns the chunk. + * @param location A location inside the target chunk. + * @param action The action executed with the loaded [Chunk]. + * @return The result produced by [action]. + */ +suspend fun World.doInChunkAsync( + location: Location, + action: (Chunk) -> R +): R = doInChunkAsync(location.chunkX, location.chunkZ, action) + +/** + * Runs an action once the chunk at the given chunk coordinates is loaded asynchronously. + * The [action] is executed on the owning tick thread of the target chunk. + * + * @receiver The world that owns the chunk. + * @param chunkX The chunk x-coordinate. + * @param chunkZ The chunk z-coordinate. + * @param action The action executed with the loaded [Chunk]. + * @return The result produced by [action]. + */ +suspend fun World.doInChunkAsync( + chunkX: Int, + chunkZ: Int, + action: (Chunk) -> R +): R { + val deferred = CompletableDeferred() + getChunkAtAsync(chunkX, chunkZ, Consumer { chunk -> + try { + deferred.complete(action(chunk)) + } catch (e: Exception) { + deferred.completeExceptionally(e) + } + }) + return deferred.await() +} +/** + * Resolves the LuckPerms user for this online player. + * + * @receiver The online player. + * @return The corresponding LuckPerms user. + * @throws IllegalStateException If no user is currently available. + */ fun Player.getLuckPermsUser() = LuckPermsAccess.getUser(this.uniqueId) ?: error("LuckPerms user not found for online player ${this.name}") +/** + * Resolves the LuckPerms user for this online player if available. + * + * @receiver The online player. + * @return The corresponding LuckPerms user or `null`. + */ fun Player.getLuckPermsUserOrNull() = LuckPermsAccess.getUser(this.uniqueId) +/** + * Resolves or loads the LuckPerms user for this offline player. + * + * @receiver The offline player. + * @return The loaded LuckPerms user. + */ suspend fun OfflinePlayer.getLuckPermsUser() = withContext(Dispatchers.IO) { LuckPermsAccess.getUser(this@getLuckPermsUser.uniqueId) ?: LuckPermsAccess.loadUser(this@getLuckPermsUser.uniqueId) } +/** + * Builds a MiniMessage component containing the player's prefix and name. + * + * @receiver The online player. + * @return The rendered name component. + */ fun Player.getPrefixedName() = miniMessage.deserialize("${this.getLuckPermsUserOrNull()?.prefix ?: ""}${this.name}")