From ac33c1545247c410c3a46c76349095043d60e725 Mon Sep 17 00:00:00 2001 From: zhibei <785740487@qq.com> Date: Tue, 14 Jul 2026 04:15:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(platform):=20=E4=BF=AE=E5=A4=8D=E5=A4=9A?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E9=80=82=E9=85=8D=E6=AD=A3=E7=A1=AE=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/platform/command/SimpleCommand.kt | 20 +- .../platform/command/SimpleCommandTest.kt | 58 +++ .../platform-bukkit-impl/build.gradle.kts | 8 + .../taboolib/platform/type/BukkitPlayer.kt | 2 +- .../platform/type/BukkitPlayerTest.kt | 42 +++ .../platform-bungee-impl/build.gradle.kts | 2 + .../kotlin/taboolib/platform/BungeeCommand.kt | 39 +- .../taboolib/platform/type/BungeePlayer.kt | 9 +- .../platform/BungeeCompatibilityTest.kt | 47 +++ platform/platform-hytale/build.gradle.kts | 9 + .../kotlin/taboolib/platform/HytaleAdapter.kt | 8 +- .../kotlin/taboolib/platform/HytaleCommand.kt | 83 ++-- .../taboolib/platform/HytaleExecutor.kt | 353 ++++++++++++++---- .../taboolib/platform/HytaleListener.kt | 36 +- .../platform/type/HytaleCommandSender.kt | 79 +++- .../taboolib/platform/type/HytalePlayer.kt | 18 +- .../platform/HytaleCompatibilityTest.kt | 283 ++++++++++++++ .../taboolib/platform/HytaleExecutorTest.kt | 265 +++++++++++++ .../taboolib/platform/HytaleListenerTest.kt | 44 +++ .../platform/type/HytaleCommandSenderTest.kt | 95 +++++ .../platform-velocity-impl/build.gradle.kts | 6 + .../taboolib/platform/VelocityAdapter.kt | 8 +- .../taboolib/platform/VelocityAdapterTest.kt | 80 ++++ 23 files changed, 1453 insertions(+), 141 deletions(-) create mode 100644 common-platform-api/src/test/kotlin/taboolib/common/platform/command/SimpleCommandTest.kt create mode 100644 platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/type/BukkitPlayerTest.kt create mode 100644 platform/platform-bungee-impl/src/test/kotlin/taboolib/platform/BungeeCompatibilityTest.kt create mode 100644 platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleCompatibilityTest.kt create mode 100644 platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleExecutorTest.kt create mode 100644 platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleListenerTest.kt create mode 100644 platform/platform-hytale/src/test/kotlin/taboolib/platform/type/HytaleCommandSenderTest.kt create mode 100644 platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityAdapterTest.kt diff --git a/common-platform-api/src/main/kotlin/taboolib/common/platform/command/SimpleCommand.kt b/common-platform-api/src/main/kotlin/taboolib/common/platform/command/SimpleCommand.kt index be77d6a6f..d64acf4c0 100644 --- a/common-platform-api/src/main/kotlin/taboolib/common/platform/command/SimpleCommand.kt +++ b/common-platform-api/src/main/kotlin/taboolib/common/platform/command/SimpleCommand.kt @@ -64,6 +64,13 @@ class SimpleCommandBody(val func: CommandComponent.() -> Unit = {}) { } } +private fun SimpleCommandBody.registerTo(component: CommandComponent) { + component.literal(name, *aliases, optional = optional, permission = permission, hidden = hidden, description = description) { + func(this) + this@registerTo.children.forEach { it.registerTo(this) } + } +} + @Suppress("DuplicatedCode") @Inject @Awake @@ -138,18 +145,7 @@ class SimpleCommandRegister : ClassVisitor(0) { command(name, alias, description, usage, permission, permissionMessage, permissionDefault, permissionChildren, newParser) { main[clazz.name]?.func?.invoke(this) body[clazz.name]?.forEach { body -> - fun register(body: SimpleCommandBody, component: CommandComponent) { - component.literal(body.name, *body.aliases, optional = body.optional, permission = body.permission, hidden = body.hidden, description = body.description) { - if (body.children.isEmpty()) { - body.func(this) - } else { - body.children.forEach { children -> - register(children, this) - } - } - } - } - register(body, this) + body.registerTo(this) } } } diff --git a/common-platform-api/src/test/kotlin/taboolib/common/platform/command/SimpleCommandTest.kt b/common-platform-api/src/test/kotlin/taboolib/common/platform/command/SimpleCommandTest.kt new file mode 100644 index 000000000..5b7b3aae1 --- /dev/null +++ b/common-platform-api/src/test/kotlin/taboolib/common/platform/command/SimpleCommandTest.kt @@ -0,0 +1,58 @@ +package taboolib.common.platform.command + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import taboolib.common.platform.command.component.CommandBase +import taboolib.common.platform.command.component.CommandComponent +import taboolib.common.platform.command.component.CommandComponentLiteral + +class SimpleCommandTest { + + @Test + fun `empty body tree still applies body function`() { + val body = SimpleCommandBody { + literal("declared") + }.apply { + name = "root" + } + val command = CommandBase() + + register(body, command) + + val root = command.children.single() as CommandComponentLiteral + assertArrayEquals(arrayOf("root"), root.aliases) + assertArrayEquals(arrayOf("declared"), (root.children.single() as CommandComponentLiteral).aliases) + } + + @Test + fun `body function and nested bodies register consistently`() { + val body = SimpleCommandBody { + literal("declared") + }.apply { + name = "root" + children += SimpleCommandBody { + literal("leaf") + }.apply { + name = "nested" + } + } + val command = CommandBase() + + register(body, command) + + val root = command.children.single() as CommandComponentLiteral + assertEquals(2, root.children.size) + assertArrayEquals(arrayOf("declared"), (root.children[0] as CommandComponentLiteral).aliases) + val nested = root.children[1] as CommandComponentLiteral + assertArrayEquals(arrayOf("nested"), nested.aliases) + assertArrayEquals(arrayOf("leaf"), (nested.children.single() as CommandComponentLiteral).aliases) + } + + private fun register(body: SimpleCommandBody, component: CommandComponent) { + val method = Class.forName("taboolib.common.platform.command.SimpleCommandKt") + .getDeclaredMethod("registerTo", SimpleCommandBody::class.java, CommandComponent::class.java) + method.isAccessible = true + method.invoke(null, body, component) + } +} diff --git a/platform/platform-bukkit-impl/build.gradle.kts b/platform/platform-bukkit-impl/build.gradle.kts index 2044a3cab..a305d0d3f 100644 --- a/platform/platform-bukkit-impl/build.gradle.kts +++ b/platform/platform-bukkit-impl/build.gradle.kts @@ -29,4 +29,12 @@ dependencies { // XSeries compileOnly("com.google.code.findbugs:jsr305:3.0.2") compileOnly("org.apache.logging.log4j:log4j-api:2.14.1") + + testImplementation(project(":common")) + testImplementation(project(":common-platform-api")) + testImplementation(project(":common-util")) + testImplementation("io.paper:folia-api:1.21.4") + testImplementation("net.kyori:adventure-api:4.17.0") + testImplementation("net.kyori:adventure-text-minimessage:4.17.0") + testImplementation("net.md-5:bungeecord-chat:1.20") } \ No newline at end of file diff --git a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/type/BukkitPlayer.kt b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/type/BukkitPlayer.kt index e996d6413..0d9812d78 100644 --- a/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/type/BukkitPlayer.kt +++ b/platform/platform-bukkit-impl/src/main/kotlin/taboolib/platform/type/BukkitPlayer.kt @@ -77,7 +77,7 @@ class BukkitPlayer(val player: Player) : ProxyPlayer { override var bedSpawnLocation: Location? get() = player.bedSpawnLocation?.toProxyLocation() set(value) { - player.bedSpawnLocation = value!!.toBukkitLocation() + player.bedSpawnLocation = value?.toBukkitLocation() } override var displayName: String? diff --git a/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/type/BukkitPlayerTest.kt b/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/type/BukkitPlayerTest.kt new file mode 100644 index 000000000..535c7fbe6 --- /dev/null +++ b/platform/platform-bukkit-impl/src/test/kotlin/taboolib/platform/type/BukkitPlayerTest.kt @@ -0,0 +1,42 @@ +package taboolib.platform.type + +import org.bukkit.entity.Player +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.lang.reflect.Proxy + +class BukkitPlayerTest { + + @Test + fun `null bed spawn location reaches bukkit setter`() { + var calls = 0 + val player = Proxy.newProxyInstance(Player::class.java.classLoader, arrayOf(Player::class.java)) { _, method, args -> + if (method.name == "setBedSpawnLocation" && method.parameterCount == 1) { + calls++ + assertNull(args?.firstOrNull()) + null + } else { + defaultValue(method.returnType) + } + } as Player + + BukkitPlayer(player).bedSpawnLocation = null + + assertEquals(1, calls) + } + + private fun defaultValue(type: Class<*>): Any? { + return when (type) { + java.lang.Boolean.TYPE -> false + java.lang.Byte.TYPE -> 0.toByte() + java.lang.Short.TYPE -> 0.toShort() + java.lang.Integer.TYPE -> 0 + java.lang.Long.TYPE -> 0L + java.lang.Float.TYPE -> 0F + java.lang.Double.TYPE -> 0.0 + java.lang.Character.TYPE -> '\u0000' + else -> null + } + } +} diff --git a/platform/platform-bungee-impl/build.gradle.kts b/platform/platform-bungee-impl/build.gradle.kts index b4922ef02..2c27b0e2e 100644 --- a/platform/platform-bungee-impl/build.gradle.kts +++ b/platform/platform-bungee-impl/build.gradle.kts @@ -4,4 +4,6 @@ dependencies { compileOnly(project(":common-platform-api")) compileOnly(project(":platform:platform-bungee")) compileOnly("net.md_5.bungee:BungeeCord:1") + + testImplementation("net.md_5.bungee:BungeeCord:1") } \ No newline at end of file diff --git a/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/BungeeCommand.kt b/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/BungeeCommand.kt index b33a3ca4b..fa326e7f9 100644 --- a/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/BungeeCommand.kt +++ b/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/BungeeCommand.kt @@ -43,20 +43,20 @@ class BungeeCommand : PlatformCommand { commandBuilder: CommandBase.() -> Unit, ) { val permission = command.permission.ifEmpty { "${plugin.description.name}.command.use" } - BungeeCord.getInstance().pluginManager.registerCommand(BungeePlugin.getInstance(), object : Command(command.name, permission), TabExecutor { - - override fun execute(sender: CommandSender, args: Array) { - executor.execute(adaptCommandSender(sender), command, command.name, args) - } - - override fun onTabComplete(sender: CommandSender, args: Array): MutableIterable { - return completer.execute(adaptCommandSender(sender), command, command.name, args)?.toMutableList() ?: ArrayList() + val registeredCommand = RegisteredBungeeCommand( + command.name, + permission, + command.aliases, + execute = { sender, args -> executor.execute(adaptCommandSender(sender), command, command.name, args) }, + complete = { sender, args -> + completer.execute(adaptCommandSender(sender), command, command.name, args)?.toMutableList() ?: ArrayList() } - }) + ) + BungeeCord.getInstance().pluginManager.registerCommand(BungeePlugin.getInstance(), registeredCommand) } override fun unregisterCommand(command: String) { - val instance = BungeeCord.getInstance().pluginManager.getProperty>("commandMap")!![command] ?: return + val instance = BungeeCord.getInstance().pluginManager.getProperty>("commandMap")?.get(command) ?: return BungeeCord.getInstance().pluginManager.unregisterCommand(instance) } @@ -82,4 +82,21 @@ class BungeeCommand : PlatformCommand { } sender.cast().sendMessage(*components.toTypedArray()) } -} \ No newline at end of file +} + +private class RegisteredBungeeCommand( + name: String, + permission: String, + aliases: List, + private val execute: (CommandSender, Array) -> Unit, + private val complete: (CommandSender, Array) -> MutableIterable, +) : Command(name, permission, *aliases.toTypedArray()), TabExecutor { + + override fun execute(sender: CommandSender, args: Array) { + execute.invoke(sender, args) + } + + override fun onTabComplete(sender: CommandSender, args: Array): MutableIterable { + return complete.invoke(sender, args) + } +} diff --git a/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/type/BungeePlayer.kt b/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/type/BungeePlayer.kt index ea4aaca0b..81975885b 100644 --- a/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/type/BungeePlayer.kt +++ b/platform/platform-bungee-impl/src/main/kotlin/taboolib/platform/type/BungeePlayer.kt @@ -277,9 +277,10 @@ class BungeePlayer(val player: ProxiedPlayer) : ProxyPlayer { } override fun sendTitle(title: String?, subtitle: String?, fadein: Int, stay: Int, fadeout: Int) { + val (titleComponent, subtitleComponent) = bungeeTitleComponents(title, subtitle) val titleMessage = BungeePlugin.getInstance().proxy.createTitle().also { - it.title(TextComponent(title ?: "")) - it.subTitle(TextComponent(title ?: "")) + it.title(titleComponent) + it.subTitle(subtitleComponent) it.fadeIn(fadein) it.stay(stay) it.fadeOut(fadeout) @@ -332,4 +333,8 @@ class BungeePlayer(val player: ProxiedPlayer) : ProxyPlayer { BungeePlayer(e.player).quitCallback.forEach { it.run() } } } +} + +private fun bungeeTitleComponents(title: String?, subtitle: String?): Pair { + return TextComponent(title ?: "") to TextComponent(subtitle ?: "") } \ No newline at end of file diff --git a/platform/platform-bungee-impl/src/test/kotlin/taboolib/platform/BungeeCompatibilityTest.kt b/platform/platform-bungee-impl/src/test/kotlin/taboolib/platform/BungeeCompatibilityTest.kt new file mode 100644 index 000000000..2e0bf36f6 --- /dev/null +++ b/platform/platform-bungee-impl/src/test/kotlin/taboolib/platform/BungeeCompatibilityTest.kt @@ -0,0 +1,47 @@ +package taboolib.platform + +import net.md_5.bungee.api.CommandSender +import net.md_5.bungee.api.chat.TextComponent +import net.md_5.bungee.api.plugin.Command +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BungeeCompatibilityTest { + + @Test + fun `registered command keeps configured aliases`() { + val command = registeredCommand(listOf("alias", "short")) + + assertEquals("main", command.name) + assertEquals("plugin.command.main", command.permission) + assertArrayEquals(arrayOf("alias", "short"), command.aliases) + } + + @Test + fun `title components keep title and subtitle independent`() { + val (title, subtitle) = titleComponents("Title", "Subtitle") + assertEquals("Title", title.text) + assertEquals("Subtitle", subtitle.text) + + val (emptyTitle, emptySubtitle) = titleComponents(null, null) + assertEquals("", emptyTitle.text) + assertEquals("", emptySubtitle.text) + } + + private fun registeredCommand(aliases: List): Command { + val constructor = Class.forName("taboolib.platform.RegisteredBungeeCommand").declaredConstructors.single() + constructor.isAccessible = true + val execute: (CommandSender, Array) -> Unit = { _, _ -> } + val complete: (CommandSender, Array) -> MutableIterable = { _, _ -> mutableListOf() } + return constructor.newInstance("main", "plugin.command.main", aliases, execute, complete) as Command + } + + @Suppress("UNCHECKED_CAST") + private fun titleComponents(title: String?, subtitle: String?): Pair { + val method = Class.forName("taboolib.platform.type.BungeePlayerKt") + .getDeclaredMethod("bungeeTitleComponents", String::class.java, String::class.java) + method.isAccessible = true + return method.invoke(null, title, subtitle) as Pair + } +} diff --git a/platform/platform-hytale/build.gradle.kts b/platform/platform-hytale/build.gradle.kts index 10ed0498b..5450d7550 100644 --- a/platform/platform-hytale/build.gradle.kts +++ b/platform/platform-hytale/build.gradle.kts @@ -3,4 +3,13 @@ dependencies { compileOnly(project(":common-util")) compileOnly(project(":common-platform-api")) compileOnly("com.hypixel:hytale-server:1.0.0") + + testImplementation(project(":common")) + testImplementation(project(":common-util")) + testImplementation(project(":common-platform-api")) + testImplementation("com.hypixel:hytale-server:1.0.0") +} + +tasks.test { + systemProperty("java.util.logging.manager", "com.hypixel.hytale.logger.backend.HytaleLogManager") } diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleAdapter.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleAdapter.kt index 45e3efc48..5782326f0 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleAdapter.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleAdapter.kt @@ -41,11 +41,15 @@ class HytaleAdapter : PlatformAdapter { } override fun adaptPlayer(any: Any): ProxyPlayer { - return HytalePlayer(any as Player) + return if (any is ProxyPlayer) any else HytalePlayer(any as Player) } override fun adaptCommandSender(any: Any): ProxyCommandSender { - return if (any is Player) adaptPlayer(any) else HytaleCommandSender(any as CommandSender) + return when (any) { + is ProxyCommandSender -> any + is Player -> adaptPlayer(any) + else -> HytaleCommandSender(any as CommandSender) + } } override fun adaptLocation(any: Any): Location { diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleCommand.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleCommand.kt index 6985a6b96..8912624c6 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleCommand.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleCommand.kt @@ -2,7 +2,10 @@ package taboolib.platform import com.hypixel.hytale.server.core.command.system.CommandContext import com.hypixel.hytale.server.core.command.system.CommandRegistration +import com.hypixel.hytale.server.core.command.system.CommandSender +import com.hypixel.hytale.server.core.command.system.arguments.types.ArgTypes import com.hypixel.hytale.server.core.command.system.basecommands.CommandBase +import com.hypixel.hytale.server.core.entity.entities.Player import taboolib.common.Inject import taboolib.common.platform.Awake import taboolib.common.platform.Platform @@ -11,9 +14,10 @@ import taboolib.common.platform.ProxyCommandSender import taboolib.common.platform.command.CommandCompleter import taboolib.common.platform.command.CommandExecutor import taboolib.common.platform.command.CommandStructure -import taboolib.common.platform.function.adaptCommandSender import taboolib.common.platform.service.PlatformCommand import taboolib.common.util.unsafeLazy +import taboolib.platform.type.HytaleCommandSender +import taboolib.platform.type.HytalePlayer import java.util.concurrent.ConcurrentHashMap import taboolib.common.platform.command.component.CommandBase as TabooLibCommandBase @@ -93,35 +97,70 @@ class HytaleCommand : PlatformCommand { private val completer: CommandCompleter, private val structure: CommandStructure ) : CommandBase(name, description) { - + init { - // 允许额外参数(TabooLib 自己处理参数解析) + val permission = commandPermission(structure.permission) setAllowsExtraArguments(true) - - // 添加别名 + withRequiredArg("argument", "", ArgTypes.STRING).suggest { sender, input, _, result -> + commandSuggestions(input) { args -> + completer.execute(adaptNativeCommandSender(sender), structure, structure.name, args) + }.forEach { result.suggest(it) } + } + permission?.let { requirePermission(it) } + addUsageVariant(object : CommandBase(description) { + + init { + permission?.let { requirePermission(it) } + } + + override fun executeSync(context: CommandContext) { + executeCommand(context, emptyArray()) + } + }) if (structure.aliases.isNotEmpty()) { addAliases(*structure.aliases.toTypedArray()) } } override fun executeSync(context: CommandContext) { - val sender = adaptCommandSender(context.sender()) - // 直接从输入字符串解析参数 - // inputString 格式: "commandName arg1 arg2 arg3" - val inputString = context.inputString - val args = if (inputString.isBlank()) { - emptyArray() - } else { - // 移除命令名,只保留参数 - val parts = inputString.split(" ").filter { it.isNotBlank() } - if (parts.size > 1) { - parts.drop(1).toTypedArray() - } else { - emptyArray() - } - } - - executor.execute(sender, structure, structure.name, args) + executeCommand(context, commandArguments(context.inputString)) } + + private fun executeCommand(context: CommandContext, args: Array) { + executor.execute(adaptNativeCommandSender(context.sender()), structure, structure.name, args) + } + } +} + +private fun adaptNativeCommandSender(sender: CommandSender): ProxyCommandSender { + return if (sender is Player) HytalePlayer(sender) else HytaleCommandSender(sender) +} + +@JvmSynthetic +internal fun commandPermission(permission: String): String? { + return permission.ifEmpty { null } +} + +@JvmSynthetic +internal fun commandArguments(input: String): Array { + val parts = input.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + return if (parts.size > 1) parts.drop(1).toTypedArray() else emptyArray() +} + +@JvmSynthetic +internal fun commandSuggestions(input: String, completer: (Array) -> List?): List { + + return completer(completionArguments(input)) ?: emptyList() +} + +@JvmSynthetic +internal fun completionArguments(input: String): Array { + if (input.isEmpty()) { + return arrayOf("") + } + val arguments = input.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }.toMutableList() + if (input.last().isWhitespace()) { + arguments += "" } + return arguments.toTypedArray() } diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleExecutor.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleExecutor.kt index 8c0002ecc..357015186 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleExecutor.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleExecutor.kt @@ -3,15 +3,23 @@ package taboolib.platform import com.hypixel.hytale.server.core.HytaleServer import taboolib.common.Inject import taboolib.common.LifeCycle +import taboolib.common.PrimitiveIO import taboolib.common.platform.Awake import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide +import taboolib.common.platform.function.registerLifeCycleTask import taboolib.common.platform.service.PlatformExecutor import taboolib.common.util.unsafeLazy import java.io.Closeable +import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException import java.util.concurrent.ScheduledFuture +import java.util.concurrent.ThreadFactory import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference /** * TabooLib @@ -23,113 +31,330 @@ import java.util.concurrent.TimeUnit @Awake @Inject @PlatformSide(Platform.HYTALE) -class HytaleExecutor : PlatformExecutor { +class HytaleExecutor private constructor( + private val taskScheduler: HytaleTaskScheduler?, + private val asyncExecutor: ExecutorService, + private val exceptionReporter: (Throwable) -> Unit, + registerStopTask: Boolean, +) : PlatformExecutor { - private val tasks = ArrayList() - private var started = false - private val executor = Executors.newFixedThreadPool(16) + constructor() : this(null, createAsyncExecutor(), ::reportTaskException, true) + + private enum class State { + NEW, RUNNING, STOPPED + } + + private val lock = Any() + private val pendingTasks = LinkedHashSet() + private val activeTasks = LinkedHashSet() + + @Volatile + private var state = State.NEW val plugin by unsafeLazy { HytalePlugin.getInstance() } + init { + if (registerStopTask) { + registerLifeCycleTask(LifeCycle.DISABLE, 2) { stop() } + } + } + @Awake(LifeCycle.ENABLE) override fun start() { - started = true + val tasks = synchronized(lock) { + when (state) { + State.NEW -> { + state = State.RUNNING + pendingTasks.filterNotTo(ArrayList()) { it.isCancelled }.also { + pendingTasks.clear() + activeTasks.addAll(it) + } + } + State.RUNNING, State.STOPPED -> return + } + } + var failure: Throwable? = null tasks.forEach { - if (it.runnable.now) { - it.executeNow() + try { + launch(it) + } catch (ex: Throwable) { + if (failure == null) { + failure = ex + } else { + failure?.addSuppressed(ex) + } + } + } + failure?.let { throw it } + } + + private fun stop() { + val tasks = synchronized(lock) { + if (state == State.STOPPED) { + return + } + state = State.STOPPED + LinkedHashSet().also { + it.addAll(pendingTasks) + it.addAll(activeTasks) + pendingTasks.clear() + activeTasks.clear() + } + } + var failure: Throwable? = null + tasks.forEach { + try { + it.platformTask().cancel() + } catch (ex: Throwable) { + if (failure == null) { + failure = ex + } else { + failure?.addSuppressed(ex) + } + } + } + try { + asyncExecutor.shutdownNow() + } catch (ex: Throwable) { + if (failure == null) { + failure = ex } else { - it.execute() + failure?.addSuppressed(ex) } } - tasks.clear() + failure?.let { throw it } } fun execute(hytaleRunningTask: HytaleRunningTask, runnable: PlatformExecutor.PlatformRunnable): ScheduledFuture<*> { - return when { - runnable.period > 0 -> HytaleServer.SCHEDULED_EXECUTOR.scheduleAtFixedRate( - { - if (runnable.async) { - executor.submit { runnable.executor(hytaleRunningTask.platformTask()) } - } else { - runnable.executor(hytaleRunningTask.platformTask()) - } - }, - runnable.delay * 50, - runnable.period * 50, - TimeUnit.MILLISECONDS - ) + val action = Runnable { executeScheduled(hytaleRunningTask, runnable) } + taskScheduler?.let { return it.schedule(runnable, action) } + return HytaleServerTaskScheduler.schedule(runnable, action) + } - runnable.delay > 0 -> HytaleServer.SCHEDULED_EXECUTOR.schedule( - { - if (runnable.async) { - executor.submit { runnable.executor(hytaleRunningTask.platformTask()) } - } else { - runnable.executor(hytaleRunningTask.platformTask()) - } - }, - runnable.delay * 50, - TimeUnit.MILLISECONDS - ) + override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { + val task = HytaleRunningTask(this, runnable) + val launchNow = synchronized(lock) { + when (state) { + State.NEW -> { + pendingTasks += task + false + } + State.RUNNING -> { + activeTasks += task + true + } + State.STOPPED -> throw RejectedExecutionException("HytaleExecutor has been stopped") + } + } + if (launchNow) { + launch(task) + } + return task.platformTask() + } - else -> HytaleServer.SCHEDULED_EXECUTOR.schedule( - { - if (runnable.async) { - executor.submit { runnable.executor(hytaleRunningTask.platformTask()) } - } else { - runnable.executor(hytaleRunningTask.platformTask()) + private fun launch(task: HytaleRunningTask) { + if (task.isCancelled) { + taskFinished(task) + return + } + if (task.runnable.now) { + try { + task.executeNow() + } finally { + taskFinished(task) + } + } else { + try { + task.execute() + } catch (ex: Throwable) { + taskFinished(task) + throw ex + } + } + } + + private fun executeScheduled(task: HytaleRunningTask, runnable: PlatformExecutor.PlatformRunnable) { + if (task.isCancelled) { + return + } + if (runnable.async) { + val started = AtomicBoolean(false) + try { + asyncExecutor.execute { + started.set(true) + executeUserTask(task, runnable) + } + } catch (ex: Throwable) { + if (!started.get()) { + reportTaskFailure(ex) + try { + task.platformTask().cancel() + } catch (cancellationFailure: Throwable) { + ex.addSuppressed(cancellationFailure) } - }, - 0, - TimeUnit.MILLISECONDS - ) + } + throw ex + } + } else { + executeUserTask(task, runnable) } } + private fun executeUserTask(task: HytaleRunningTask, runnable: PlatformExecutor.PlatformRunnable) { + if (task.isCancelled) { + return + } + try { + runnable.executor(task.platformTask()) + } catch (ex: Throwable) { + reportTaskFailure(ex) + if (!runnable.async) { + taskFinished(task) + } + throw ex + } finally { + if (runnable.period <= 0) { + taskFinished(task) + } + } + } + + private fun reportTaskFailure(ex: Throwable) { + try { + exceptionReporter(ex) + } catch (reportingFailure: Throwable) { + ex.addSuppressed(reportingFailure) + } + } + + private fun taskFinished(task: HytaleRunningTask) { + synchronized(lock) { + pendingTasks -= task + activeTasks -= task + } + } + + private fun taskCancelled(task: HytaleRunningTask) { + taskFinished(task) + } + class HytaleRunningTask(val executor: HytaleExecutor, val runnable: PlatformExecutor.PlatformRunnable) { lateinit var scheduledTask: ScheduledFuture<*> + private val cancelled = AtomicBoolean(false) + private val scheduledTaskReference = AtomicReference?>() + private val scheduledTaskCancelled = AtomicBoolean(false) + + @get:JvmSynthetic + internal val isCancelled: Boolean + get() = cancelled.get() + fun executeNow() { - runnable.executor(HytalePlatformTask { }) + if (!isCancelled) { + executor.executeUserTask(this, runnable) + } } fun execute() { - scheduledTask = executor.execute(this, runnable) + if (isCancelled) { + return + } + val task = executor.execute(this, runnable) + scheduledTask = task + bind(task) } fun platformTask(): PlatformExecutor.PlatformTask { - return HytalePlatformTask { scheduledTask.cancel(false) } + return HytalePlatformTask { cancel() } } - } - override fun submit(runnable: PlatformExecutor.PlatformRunnable): PlatformExecutor.PlatformTask { - val task = HytaleRunningTask(this, runnable) + private fun cancel() { + if (cancelled.compareAndSet(false, true)) { + try { + val scheduled = scheduledTaskReference.get() + ?: if (this::scheduledTask.isInitialized) scheduledTask else null + scheduled?.let(::cancelScheduledTask) + } finally { + executor.taskCancelled(this) + } + } + } - return if (started) { - if (runnable.now) { - task.executeNow() - HytalePlatformTask { } - } else { - task.execute() - task.platformTask() + private fun bind(task: ScheduledFuture<*>) { + check(scheduledTaskReference.compareAndSet(null, task)) { "Scheduled task is already bound" } + if (isCancelled) { + cancelScheduledTask(task) } - } else { - tasks += task - HytalePlatformTask { - if (!runnable.now) { - task.platformTask().cancel() - } - tasks -= task + } + + private fun cancelScheduledTask(task: ScheduledFuture<*>) { + if (scheduledTaskCancelled.compareAndSet(false, true)) { + task.cancel(false) } } } class HytalePlatformTask(val runnable: Closeable) : PlatformExecutor.PlatformTask { + private val cancelled = AtomicBoolean(false) + override fun cancel() { - runnable.close() + if (cancelled.compareAndSet(false, true)) { + runnable.close() + } } } + + companion object { + + private fun createAsyncExecutor(): ExecutorService { + return Executors.newFixedThreadPool(16, HytaleAsyncThreadFactory()) + } + + private fun reportTaskException(ex: Throwable) { + try { + HytalePlugin.getInstance().logger.atSevere().withCause(ex) + .log("Unhandled exception in a TabooLib Hytale task") + } catch (_: Throwable) { + PrimitiveIO.error("Unhandled exception in a TabooLib Hytale task: ${ex.message}") + ex.printStackTrace() + } + } + } +} + +private fun interface HytaleTaskScheduler { + + fun schedule(runnable: PlatformExecutor.PlatformRunnable, action: Runnable): ScheduledFuture<*> +} + +private object HytaleServerTaskScheduler : HytaleTaskScheduler { + + override fun schedule(runnable: PlatformExecutor.PlatformRunnable, action: Runnable): ScheduledFuture<*> { + return when { + runnable.period > 0 -> HytaleServer.SCHEDULED_EXECUTOR.scheduleAtFixedRate( + action, + runnable.delay * 50, + runnable.period * 50, + TimeUnit.MILLISECONDS + ) + else -> HytaleServer.SCHEDULED_EXECUTOR.schedule( + action, + runnable.delay * 50, + TimeUnit.MILLISECONDS + ) + } + } +} + +private class HytaleAsyncThreadFactory : ThreadFactory { + + private val counter = AtomicInteger() + + override fun newThread(runnable: Runnable): Thread { + return Thread(runnable, "TabooLib-Hytale-Async-${counter.incrementAndGet()}") + } } diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleListener.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleListener.kt index 10312497d..e2e03a7e8 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleListener.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/HytaleListener.kt @@ -5,8 +5,10 @@ import com.hypixel.hytale.component.system.ISystem import com.hypixel.hytale.event.EventRegistration import com.hypixel.hytale.event.IAsyncEvent import com.hypixel.hytale.event.IBaseEvent +import com.hypixel.hytale.server.core.event.events.player.PlayerDisconnectEvent import com.hypixel.hytale.server.core.universe.world.storage.EntityStore import taboolib.common.Inject +import taboolib.common.LifeCycle import taboolib.common.platform.Awake import taboolib.common.platform.Platform import taboolib.common.platform.PlatformSide @@ -16,6 +18,7 @@ import taboolib.common.platform.event.PostOrder import taboolib.common.platform.event.ProxyListener import taboolib.common.platform.service.PlatformListener import taboolib.common.util.unsafeLazy +import taboolib.platform.type.HytaleCommandSender import java.util.concurrent.CompletableFuture import java.util.function.Consumer import java.util.function.Function @@ -34,6 +37,18 @@ class HytaleListener : PlatformListener { val plugin by unsafeLazy { HytalePlugin.getInstance() } + @Awake(LifeCycle.ENABLE) + private fun registerPlayerDisconnectListener() { + plugin.eventRegistry.register(PlayerDisconnectEvent::class.java, Consumer { event -> + HytaleCommandSender.fireQuitCallbacks(event.playerRef) + }) + } + + @Awake(LifeCycle.DISABLE) + private fun clearPlayerQuitCallbacks() { + HytaleCommandSender.clearQuitCallbacks() + } + override fun registerListener(event: Class, priority: EventPriority, ignoreCancelled: Boolean, func: (T) -> Unit): ProxyListener { error("Unsupported") } @@ -76,8 +91,11 @@ class HytaleListener : PlatformListener { val priority = handler.priority val key = handler.key val eventClass = event as Class> - val function = Function>, CompletableFuture>> { cf -> - (handler.func as Function, CompletableFuture>).apply(cf as CompletableFuture) as CompletableFuture> + val function = Function>, CompletableFuture>> { future -> + invokeAsyncHandler( + future, + handler.func as Function>, CompletableFuture>> + ) } val registration: EventRegistration<*, *>? = when (handler) { is HytaleEventHandler.Async -> if (key != null) { @@ -109,3 +127,17 @@ class HytaleListener : PlatformListener { class HytaleEcsProxyListener(val system: HytaleEcsEventSystem<*>) : ProxyListener } + +@JvmSynthetic +internal fun invokeAsyncHandler( + future: CompletableFuture, + handler: Function, CompletableFuture>, +): CompletableFuture { + return try { + (handler.apply(future) as CompletableFuture?) ?: CompletableFuture().also { + it.completeExceptionally(NullPointerException("Async event handler returned null")) + } + } catch (ex: Throwable) { + CompletableFuture().also { it.completeExceptionally(ex) } + } +} diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytaleCommandSender.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytaleCommandSender.kt index 911f19836..95c4af59c 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytaleCommandSender.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytaleCommandSender.kt @@ -6,6 +6,8 @@ import com.hypixel.hytale.server.core.command.system.CommandSender import com.hypixel.hytale.server.core.console.ConsoleSender import com.hypixel.hytale.server.core.permissions.PermissionsModule import taboolib.common.platform.ProxyCommandSender +import java.util.WeakHashMap +import java.util.concurrent.CompletableFuture /** * TabooLib @@ -21,6 +23,67 @@ open class HytaleCommandSender(val sender: CommandSender) : ProxyCommandSender { private val COLOR_PATTERN = Regex("§.") fun stripColor(message: String): String = message.replace(COLOR_PATTERN, "") + + @JvmSynthetic + internal fun dispatchCommand(dispatch: () -> CompletableFuture): Boolean { + dispatch() + return true + } + + private val quitLock = Any() + private val quitCallbacks = WeakHashMap>() + private val completedQuitSessions = WeakHashMap() + + @JvmSynthetic + internal fun activateQuitSession(session: Any) { + synchronized(quitLock) { + completedQuitSessions.remove(session) + } + } + + @JvmSynthetic + internal fun registerQuitCallback(session: Any, callback: Runnable) { + val runImmediately = synchronized(quitLock) { + if (completedQuitSessions.containsKey(session)) { + true + } else { + quitCallbacks.getOrPut(session) { LinkedHashSet() }.add(callback) + false + } + } + if (runImmediately) { + callback.run() + } + } + + @JvmSynthetic + internal fun fireQuitCallbacks(session: Any) { + val registered = synchronized(quitLock) { + completedQuitSessions[session] = true + quitCallbacks.remove(session)?.toList().orEmpty() + } + var failure: Throwable? = null + registered.forEach { + try { + it.run() + } catch (ex: Throwable) { + if (failure == null) { + failure = ex + } else { + failure?.addSuppressed(ex) + } + } + } + failure?.let { throw it } + } + + @JvmSynthetic + internal fun clearQuitCallbacks() { + synchronized(quitLock) { + quitCallbacks.clear() + completedQuitSessions.clear() + } + } } override val origin: Any @@ -52,13 +115,7 @@ open class HytaleCommandSender(val sender: CommandSender) : ProxyCommandSender { } override fun performCommand(command: String): Boolean { - val future = CommandManager.get().handleCommand(sender, command) - return try { - future.get() - true - } catch (e: Exception) { - false - } + return dispatchCommand { CommandManager.get().handleCommand(sender, command) } } override fun hasPermission(permission: String): Boolean { @@ -92,13 +149,7 @@ open class HytaleCommandSender(val sender: CommandSender) : ProxyCommandSender { } override fun performCommand(command: String): Boolean { - val future = CommandManager.get().handleCommand(console, command) - return try { - future.get() - true - } catch (e: Exception) { - false - } + return dispatchCommand { CommandManager.get().handleCommand(console, command) } } override fun hasPermission(permission: String): Boolean { diff --git a/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytalePlayer.kt b/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytalePlayer.kt index ef51661ed..316f6f7b3 100644 --- a/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytalePlayer.kt +++ b/platform/platform-hytale/src/main/kotlin/taboolib/platform/type/HytalePlayer.kt @@ -3,7 +3,6 @@ package taboolib.platform.type import com.hypixel.hytale.protocol.GameMode import com.hypixel.hytale.protocol.packets.connection.PongType import com.hypixel.hytale.server.core.Message -import com.hypixel.hytale.server.core.command.system.CommandManager import com.hypixel.hytale.server.core.entity.entities.Player import com.hypixel.hytale.server.core.permissions.PermissionsModule import taboolib.common.platform.ProxyGameMode @@ -23,6 +22,12 @@ import java.util.* @Suppress("removal") class HytalePlayer(val player: Player) : ProxyPlayer { + init { + if (isOnline()) { + HytaleCommandSender.activateQuitSession(player.playerRef) + } + } + override val origin: Any get() = player @@ -323,13 +328,8 @@ class HytalePlayer(val player: Player) : ProxyPlayer { } override fun performCommand(command: String): Boolean { - // 使用 CommandManager 执行命令 - val future = CommandManager.get().handleCommand(player, command) - return try { - future.get() // 等待命令执行完成 - true - } catch (e: Exception) { - false + return HytaleCommandSender.dispatchCommand { + com.hypixel.hytale.server.core.command.system.CommandManager.get().handleCommand(player, command) } } @@ -346,6 +346,6 @@ class HytalePlayer(val player: Player) : ProxyPlayer { } override fun onQuit(callback: Runnable) { - // TODO: 实现退出回调 + HytaleCommandSender.registerQuitCallback(player.playerRef, callback) } } diff --git a/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleCompatibilityTest.kt b/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleCompatibilityTest.kt new file mode 100644 index 000000000..32c637d4c --- /dev/null +++ b/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleCompatibilityTest.kt @@ -0,0 +1,283 @@ +package taboolib.platform + +import com.hypixel.hytale.server.core.command.system.AbstractCommand +import com.hypixel.hytale.server.core.command.system.CommandSender +import com.hypixel.hytale.server.core.command.system.ParseResult +import com.hypixel.hytale.server.core.command.system.ParserContext +import com.hypixel.hytale.server.core.command.system.Tokenizer +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import taboolib.common.platform.ProxyCommandSender +import taboolib.common.platform.ProxyPlayer +import taboolib.common.platform.command.CommandCompleter +import taboolib.common.platform.command.CommandExecutor +import taboolib.common.platform.command.CommandStructure +import taboolib.common.platform.command.PermissionDefault +import taboolib.platform.type.HytaleCommandSender +import java.lang.reflect.Proxy +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicInteger +import java.util.function.Function + +class HytaleCompatibilityTest { + + @Test + fun `command keeps explicit permission and leaves empty permission native`() { + assertEquals("plugin.command.root", commandPermission("plugin.command.root")) + assertEquals(null, commandPermission("")) + } + + @Test + fun `native first positional argument owns completer and zero argument variant`() { + var received = emptyArray() + val command = HytaleCommand.TabooLibHytaleCommand( + "root", + "description", + executor(), + completer { + received = it + listOf("two") + }, + structure(permission = "plugin.command.root", aliases = listOf("alias")), + ) + val argument = command.requiredArguments.single() + val variantsField = Class.forName("com.hypixel.hytale.server.core.command.system.AbstractCommand") + .getDeclaredField("variantCommands") + variantsField.isAccessible = true + val variants = variantsField.get(command) as Map<*, *> + + assertEquals("plugin.command.root", command.permission) + assertTrue(command.aliases.contains("alias")) + assertFalse(argument.argumentType.isListArgument) + assertEquals(listOf("two"), argument.getSuggestions(nativeSender(), arrayOf("tw"))) + assertArrayEquals(arrayOf("tw"), received) + assertEquals("plugin.command.root", (variants[0] as AbstractCommand).permission) + } + + @Test + fun `command arguments keep positional input semantics`() { + assertArrayEquals(emptyArray(), commandArguments("root")) + assertArrayEquals(arrayOf("one", "two"), commandArguments("root one two")) + assertArrayEquals(arrayOf("one"), commandArguments(" root one ")) + } + + @Test + fun `native command accepts zero and ordinary multi positional arguments`() { + val executions = ArrayList>() + val command = HytaleCommand.TabooLibHytaleCommand( + "root", + "description", + executor { executions += it }, + completer(), + structure(), + ) + + accept(command, "root") + accept(command, "root one two") + + assertEquals(2, executions.size) + assertArrayEquals(emptyArray(), executions[0]) + assertArrayEquals(arrayOf("one", "two"), executions[1]) + } + + @Test + fun `completion preserves current empty argument and invokes completer`() { + assertArrayEquals(arrayOf(""), completionArguments("")) + assertArrayEquals(arrayOf("one", ""), completionArguments("one ")) + assertArrayEquals(arrayOf("one", "two"), completionArguments("one two")) + + var received = emptyArray() + val suggestions = commandSuggestions("one ") { + received = it + listOf("two") + } + + assertArrayEquals(arrayOf("one", ""), received) + assertEquals(listOf("two"), suggestions) + } + + @Test + fun `existing proxy senders keep identity`() { + val adapter = HytaleAdapter() + val player = proxy() + val sender = proxy() + + assertSame(player, adapter.adaptPlayer(player)) + assertSame(player, adapter.adaptCommandSender(player)) + assertSame(sender, adapter.adaptCommandSender(sender)) + } + + @Test + fun `native command sender uses hytale wrapper`() { + val adapter = HytaleAdapter() + val sender = proxy() + + val adapted = adapter.adaptCommandSender(sender) + + assertTrue(adapted is HytaleCommandSender) + assertSame(sender, adapted.origin) + } + + @Test + fun `command dispatch never waits for incomplete future`() { + val future = CompletableFuture() + + assertTrue(HytaleCommandSender.dispatchCommand { future }) + assertFalse(future.isDone) + + future.completeExceptionally(IllegalStateException("late failure")) + assertTrue(future.isCompletedExceptionally) + } + + @Test + fun `command dispatch uses stable submission result`() { + val failed = CompletableFuture().also { + it.completeExceptionally(IllegalStateException("failed")) + } + val cancelled = CompletableFuture().also { it.cancel(false) } + + assertTrue(HytaleCommandSender.dispatchCommand { failed }) + assertTrue(HytaleCommandSender.dispatchCommand { cancelled }) + } + + @Test + fun `async listener converts synchronous throw to failed future`() { + val failure = IllegalStateException("boom") + val result = invokeAsyncHandler(CompletableFuture(), Function { throw failure }) + var observed: Throwable? = null + result.whenComplete { _, ex -> observed = ex } + + assertTrue(result.isCompletedExceptionally) + assertSame(failure, observed) + } + + @Test + fun `async listener rejects null future without blocking`() { + @Suppress("UNCHECKED_CAST") + val nullHandler = Proxy.newProxyInstance( + HytaleCompatibilityTest::class.java.classLoader, + arrayOf(Function::class.java), + ) { _, method, _ -> if (method.name == "apply") null else defaultValue(method.returnType) } + as Function, CompletableFuture> + val result = invokeAsyncHandler(CompletableFuture(), nullHandler) + var observed: Throwable? = null + result.whenComplete { _, ex -> observed = ex } + + assertTrue(result.isCompletedExceptionally) + assertTrue(observed is NullPointerException) + } + + @Test + fun `quit callbacks run once and are removed`() { + val session = Any() + val first = AtomicInteger() + val second = AtomicInteger() + HytaleCommandSender.registerQuitCallback(session, Runnable(first::incrementAndGet)) + HytaleCommandSender.registerQuitCallback(session, Runnable(second::incrementAndGet)) + + HytaleCommandSender.fireQuitCallbacks(session) + HytaleCommandSender.fireQuitCallbacks(session) + + assertEquals(1, first.get()) + assertEquals(1, second.get()) + } + + private fun structure(permission: String = "", aliases: List = emptyList()): CommandStructure { + return CommandStructure( + "root", + aliases, + "description", + "", + permission, + "", + PermissionDefault.TRUE, + emptyMap(), + false, + ) + } + + private fun accept(command: HytaleCommand.TabooLibHytaleCommand, input: String) { + val result = ParseResult() + val tokens = requireNotNull(Tokenizer.parseArguments(input, result)) + val parser = ParserContext.of(tokens, result) + val future = command.acceptCall(nativeSender(), parser, result) + + assertFalse(result.failed()) + future?.let { + assertTrue(it.isDone) + assertFalse(it.isCompletedExceptionally) + } + } + + private fun executor(block: (Array) -> Unit = {}): CommandExecutor { + return object : CommandExecutor { + override fun execute( + sender: ProxyCommandSender, + command: CommandStructure, + name: String, + args: Array, + ): Boolean { + block(args) + return true + } + } + } + + private fun completer(block: (Array) -> List = { emptyList() }): CommandCompleter { + return object : CommandCompleter { + override fun execute( + sender: ProxyCommandSender, + command: CommandStructure, + name: String, + args: Array, + ): List { + return block(args) + } + } + } + + private fun nativeSender(): CommandSender { + val uuid = UUID.randomUUID() + return Proxy.newProxyInstance(CommandSender::class.java.classLoader, arrayOf(CommandSender::class.java)) { instance, method, args -> + when (method.name) { + "hasPermission" -> true + "getDisplayName" -> "sender" + "getUuid" -> uuid + "equals" -> instance === args?.firstOrNull() + "hashCode" -> System.identityHashCode(instance) + "toString" -> "CommandSenderProxy" + else -> defaultValue(method.returnType) + } + } as CommandSender + } + + private inline fun proxy(): T { + return Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { instance, method, args -> + when (method.name) { + "equals" -> instance === args?.firstOrNull() + "hashCode" -> System.identityHashCode(instance) + "toString" -> "${T::class.java.simpleName}Proxy" + else -> defaultValue(method.returnType) + } + } as T + } + + private fun defaultValue(type: Class<*>): Any? { + return when (type) { + java.lang.Boolean.TYPE -> false + java.lang.Byte.TYPE -> 0.toByte() + java.lang.Short.TYPE -> 0.toShort() + java.lang.Integer.TYPE -> 0 + java.lang.Long.TYPE -> 0L + java.lang.Float.TYPE -> 0F + java.lang.Double.TYPE -> 0.0 + java.lang.Character.TYPE -> '\u0000' + else -> null + } + } +} diff --git a/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleExecutorTest.kt b/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleExecutorTest.kt new file mode 100644 index 000000000..f83b130e0 --- /dev/null +++ b/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleExecutorTest.kt @@ -0,0 +1,265 @@ +package taboolib.platform + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import taboolib.common.platform.service.PlatformExecutor +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Proxy +import java.util.ArrayDeque +import java.util.concurrent.AbstractExecutorService +import java.util.concurrent.ExecutorService +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +class HytaleExecutorTest { + + @Test + fun `cancelled pending task never reaches scheduler`() { + val scheduler = RecordingScheduler() + val executor = executor(scheduler) + val task = executor.submit(runnable()) + + task.cancel() + executor.start() + + assertEquals(0, scheduler.scheduleCount) + } + + @Test + fun `synchronous scheduled action propagates and reports user exception`() { + val scheduler = RecordingScheduler() + val failures = ArrayList() + val executor = executor(scheduler, failures = failures) + val failure = IllegalStateException("boom") + executor.start() + executor.submit(runnable { throw failure }) + + val thrown = assertThrows(IllegalStateException::class.java) { + scheduler.action.run() + } + + assertSame(failure, thrown) + assertEquals(listOf(failure), failures) + } + + @Test + fun `async task remains offloaded from scheduler action`() { + val scheduler = RecordingScheduler() + val async = RecordingExecutorService() + val executor = executor(scheduler, async) + var calls = 0 + executor.start() + executor.submit(runnable(async = true) { calls++ }) + + scheduler.action.run() + + assertEquals(0, calls) + assertEquals(1, async.queuedTaskCount) + async.runNext() + assertEquals(1, calls) + } + + @Test + fun `periodic synchronous failure removes active task`() { + val scheduler = RecordingScheduler() + val failures = ArrayList() + val executor = executor(scheduler, failures = failures) + val failure = IllegalStateException("boom") + executor.start() + executor.submit(runnable(period = 1) { throw failure }) + + assertSame(failure, assertThrows(IllegalStateException::class.java) { scheduler.action.run() }) + stop(executor) + + assertEquals(listOf(failure), failures) + assertEquals(0, scheduler.cancelCount) + } + + @Test + fun `periodic async failure does not stop scheduler trigger`() { + val scheduler = RecordingScheduler() + val async = RecordingExecutorService() + val failures = ArrayList() + val executor = executor(scheduler, async, failures) + val failure = IllegalStateException("boom") + executor.start() + executor.submit(runnable(async = true, period = 1) { throw failure }) + + scheduler.action.run() + scheduler.action.run() + + assertEquals(2, async.queuedTaskCount) + repeat(2) { + assertSame(failure, assertThrows(IllegalStateException::class.java) { async.runNext() }) + } + assertEquals(listOf(failure, failure), failures) + } + + @Test + fun `task cancellation reaches scheduled future once`() { + val scheduler = RecordingScheduler() + val executor = executor(scheduler) + executor.start() + + val task = executor.submit(runnable(delay = 2, period = 3)) + task.cancel() + task.cancel() + + assertEquals(1, scheduler.cancelCount) + assertEquals(2, scheduler.runnable.delay) + assertEquals(3, scheduler.runnable.period) + } + + @Test + fun `stop cancels active tasks shuts down executor and rejects submissions`() { + val scheduler = RecordingScheduler() + val async = RecordingExecutorService() + val executor = executor(scheduler, async) + executor.start() + executor.submit(runnable(delay = 1)) + + stop(executor) + + assertEquals(1, scheduler.cancelCount) + assertTrue(async.isShutdown) + assertThrows(RejectedExecutionException::class.java) { + executor.submit(runnable()) + } + } + + @Test + fun `public constructor and scheduled task field remain available`() { + HytaleExecutor::class.java.getConstructor() + assertEquals(ScheduledFuture::class.java, HytaleExecutor.HytaleRunningTask::class.java.getField("scheduledTask").type) + } + + private fun executor( + scheduler: RecordingScheduler, + async: RecordingExecutorService = RecordingExecutorService(), + failures: MutableList = ArrayList(), + ): HytaleExecutor { + val schedulerType = Class.forName("taboolib.platform.HytaleTaskScheduler") + val schedulerProxy = Proxy.newProxyInstance( + schedulerType.classLoader, + arrayOf(schedulerType), + ) { proxy, method, args -> + when (method.name) { + "schedule" -> { + val callArgs = requireNotNull(args) + scheduler.schedule( + callArgs[0] as PlatformExecutor.PlatformRunnable, + callArgs[1] as Runnable, + ) + } + "toString" -> "RecordingSchedulerProxy" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.firstOrNull() + else -> null + } + } + val constructor = HytaleExecutor::class.java.getDeclaredConstructor( + schedulerType, + ExecutorService::class.java, + Class.forName("kotlin.jvm.functions.Function1"), + java.lang.Boolean.TYPE, + ) + constructor.isAccessible = true + val reporter: (Throwable) -> Unit = { failures.add(it) } + return constructor.newInstance(schedulerProxy, async, reporter, false) + } + + private fun stop(executor: HytaleExecutor) { + val method = HytaleExecutor::class.java.getDeclaredMethod("stop") + method.isAccessible = true + try { + method.invoke(executor) + } catch (ex: InvocationTargetException) { + throw ex.cause ?: ex + } + } + + private fun runnable( + now: Boolean = false, + async: Boolean = false, + delay: Long = 0, + period: Long = 0, + block: PlatformExecutor.PlatformTask.() -> Unit = {}, + ): PlatformExecutor.PlatformRunnable { + return PlatformExecutor.PlatformRunnable(now, async, delay, period, block) + } + + private class RecordingScheduler { + + var scheduleCount = 0 + var cancelCount = 0 + lateinit var action: Runnable + lateinit var runnable: PlatformExecutor.PlatformRunnable + + fun schedule(runnable: PlatformExecutor.PlatformRunnable, action: Runnable): ScheduledFuture<*> { + scheduleCount++ + this.runnable = runnable + this.action = action + return Proxy.newProxyInstance( + ScheduledFuture::class.java.classLoader, + arrayOf(ScheduledFuture::class.java), + ) { proxy, method, args -> + when (method.name) { + "cancel" -> { + cancelCount++ + true + } + "isCancelled", "isDone" -> false + "toString" -> "RecordedScheduledFuture" + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.firstOrNull() + else -> 0 + } + } as ScheduledFuture<*> + } + } + + private class RecordingExecutorService : AbstractExecutorService() { + + private val tasks = ArrayDeque() + private var stopped = false + + val queuedTaskCount: Int + get() = tasks.size + + override fun execute(command: Runnable) { + if (stopped) { + throw RejectedExecutionException("executor stopped") + } + tasks += command + } + + fun runNext() { + tasks.removeFirst().run() + } + + override fun shutdown() { + stopped = true + } + + override fun shutdownNow(): MutableList { + stopped = true + return ArrayList(tasks).also { tasks.clear() } + } + + override fun isShutdown(): Boolean { + return stopped + } + + override fun isTerminated(): Boolean { + return stopped && tasks.isEmpty() + } + + override fun awaitTermination(timeout: Long, unit: TimeUnit): Boolean { + return isTerminated + } + } +} diff --git a/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleListenerTest.kt b/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleListenerTest.kt new file mode 100644 index 000000000..80b540998 --- /dev/null +++ b/platform/platform-hytale/src/test/kotlin/taboolib/platform/HytaleListenerTest.kt @@ -0,0 +1,44 @@ +package taboolib.platform + +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.concurrent.CompletableFuture +import java.util.function.Function + +class HytaleListenerTest { + + @Test + fun `synchronous async handler failure becomes exceptional future`() { + val source = CompletableFuture() + val failure = IllegalStateException("boom") + + val result = invokeAsyncHandler(source, Function { throw failure }) + var captured: Throwable? = null + result.whenComplete { _, throwable -> captured = throwable } + + assertTrue(result.isCompletedExceptionally) + assertSame(failure, captured) + } + + @Test + fun `cancelled future is returned without replacement`() { + val source = CompletableFuture() + source.cancel(false) + + val result = invokeAsyncHandler(source, Function { it }) + + assertSame(source, result) + assertTrue(result.isCancelled) + } + + @Test + fun `handler result future is preserved`() { + val source = CompletableFuture() + val transformed = CompletableFuture() + + val result = invokeAsyncHandler(source, Function { transformed }) + + assertSame(transformed, result) + } +} diff --git a/platform/platform-hytale/src/test/kotlin/taboolib/platform/type/HytaleCommandSenderTest.kt b/platform/platform-hytale/src/test/kotlin/taboolib/platform/type/HytaleCommandSenderTest.kt new file mode 100644 index 000000000..e17c17aaf --- /dev/null +++ b/platform/platform-hytale/src/test/kotlin/taboolib/platform/type/HytaleCommandSenderTest.kt @@ -0,0 +1,95 @@ +package taboolib.platform.type + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.concurrent.CompletableFuture + +class HytaleCommandSenderTest { + + @Test + fun `command dispatch never waits for pending future`() { + val pending = CompletableFuture() + + assertTrue(HytaleCommandSender.dispatchCommand { pending }) + } + + @Test + fun `command dispatch reports successful submission regardless of later state`() { + val cancelled = CompletableFuture() + cancelled.cancel(false) + val failed = CompletableFuture() + failed.completeExceptionally(IllegalStateException("boom")) + + assertTrue(HytaleCommandSender.dispatchCommand { cancelled }) + assertTrue(HytaleCommandSender.dispatchCommand { failed }) + } + + @Test + fun `command dispatch propagates synchronous failure`() { + val failure = IllegalStateException("boom") + + val thrown = assertThrows(IllegalStateException::class.java) { + HytaleCommandSender.dispatchCommand { throw failure } + } + + assertSame(failure, thrown) + } + + @Test + fun `quit callbacks run once across wrapper instances`() { + val session = Any() + var calls = 0 + HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }) + HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }) + + HytaleCommandSender.fireQuitCallbacks(session) + HytaleCommandSender.fireQuitCallbacks(session) + + assertEquals(2, calls) + } + + @Test + fun `late quit registration runs immediately until a new session activates`() { + val session = Any() + var calls = 0 + HytaleCommandSender.fireQuitCallbacks(session) + + HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }) + HytaleCommandSender.activateQuitSession(session) + HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }) + HytaleCommandSender.fireQuitCallbacks(session) + + assertEquals(2, calls) + } + + @Test + fun `clearing quit callbacks releases pending registrations`() { + val session = Any() + var calls = 0 + HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }) + + HytaleCommandSender.clearQuitCallbacks() + HytaleCommandSender.fireQuitCallbacks(session) + + assertEquals(0, calls) + } + + @Test + fun `quit callback failure does not skip remaining callbacks`() { + val session = Any() + val failure = IllegalStateException("boom") + var calls = 0 + HytaleCommandSender.registerQuitCallback(session, Runnable { throw failure }) + HytaleCommandSender.registerQuitCallback(session, Runnable { calls++ }) + + val thrown = assertThrows(IllegalStateException::class.java) { + HytaleCommandSender.fireQuitCallbacks(session) + } + + assertSame(failure, thrown) + assertEquals(1, calls) + } +} diff --git a/platform/platform-velocity-impl/build.gradle.kts b/platform/platform-velocity-impl/build.gradle.kts index bed6616b4..db2936291 100644 --- a/platform/platform-velocity-impl/build.gradle.kts +++ b/platform/platform-velocity-impl/build.gradle.kts @@ -8,4 +8,10 @@ dependencies { compileOnly(project(":common-platform-api")) compileOnly(project(":platform:platform-velocity")) compileOnly("com.velocitypowered:velocity-api:3.1.1") + + testImplementation(project(":common")) + testImplementation(project(":common-util")) + testImplementation(project(":common-platform-api")) + testImplementation(project(":platform:platform-velocity")) + testImplementation("com.velocitypowered:velocity-api:3.1.1") } \ No newline at end of file diff --git a/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityAdapter.kt b/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityAdapter.kt index c26fb447f..d432f2052 100644 --- a/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityAdapter.kt +++ b/platform/platform-velocity-impl/src/main/kotlin/taboolib/platform/VelocityAdapter.kt @@ -33,11 +33,15 @@ class VelocityAdapter : PlatformAdapter { } override fun adaptPlayer(any: Any): ProxyPlayer { - return VelocityPlayer(any as Player) + return if (any is ProxyPlayer) any else VelocityPlayer(any as Player) } override fun adaptCommandSender(any: Any): ProxyCommandSender { - return if (any is Player) adaptPlayer(any) else VelocityCommandSender(any as CommandSource) + return when (any) { + is ProxyCommandSender -> any + is Player -> adaptPlayer(any) + else -> VelocityCommandSender(any as CommandSource) + } } override fun adaptLocation(any: Any): Location { diff --git a/platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityAdapterTest.kt b/platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityAdapterTest.kt new file mode 100644 index 000000000..7b9fdcc6f --- /dev/null +++ b/platform/platform-velocity-impl/src/test/kotlin/taboolib/platform/VelocityAdapterTest.kt @@ -0,0 +1,80 @@ +package taboolib.platform + +import com.velocitypowered.api.command.CommandSource +import com.velocitypowered.api.proxy.Player +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import taboolib.common.platform.ProxyCommandSender +import taboolib.common.platform.ProxyPlayer +import taboolib.platform.type.VelocityCommandSender +import taboolib.platform.type.VelocityPlayer +import java.lang.reflect.Proxy + +class VelocityAdapterTest { + + private val adapter = VelocityAdapter() + + @Test + fun `existing proxy player keeps identity in both adapter paths`() { + val player = proxy() + + assertSame(player, adapter.adaptPlayer(player)) + assertSame(player, adapter.adaptCommandSender(player)) + } + + @Test + fun `existing proxy command sender keeps identity`() { + val sender = proxy() + + assertSame(sender, adapter.adaptCommandSender(sender)) + } + + @Test + fun `velocity player remains a proxy player through sender adapter`() { + val player = proxy() + + val adaptedPlayer = adapter.adaptPlayer(player) + val adaptedSender = adapter.adaptCommandSender(player) + + assertTrue(adaptedPlayer is VelocityPlayer) + assertTrue(adaptedSender is VelocityPlayer) + assertSame(player, adaptedPlayer.origin) + assertSame(player, adaptedSender.origin) + } + + @Test + fun `non-player command source uses command sender adapter`() { + val sender = proxy() + + val adapted = adapter.adaptCommandSender(sender) + + assertTrue(adapted is VelocityCommandSender) + assertSame(sender, adapted.origin) + } + + private inline fun proxy(): T { + return Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { instance, method, args -> + when (method.name) { + "equals" -> instance === args?.firstOrNull() + "hashCode" -> System.identityHashCode(instance) + "toString" -> "${T::class.java.simpleName}Proxy" + else -> defaultValue(method.returnType) + } + } as T + } + + private fun defaultValue(type: Class<*>): Any? { + return when (type) { + java.lang.Boolean.TYPE -> false + java.lang.Byte.TYPE -> 0.toByte() + java.lang.Short.TYPE -> 0.toShort() + java.lang.Integer.TYPE -> 0 + java.lang.Long.TYPE -> 0L + java.lang.Float.TYPE -> 0F + java.lang.Double.TYPE -> 0.0 + java.lang.Character.TYPE -> '\u0000' + else -> null + } + } +}