test: 1.12 integration tests

This commit is contained in:
kernitus committed 2025-12-26 20:15:21 +01:00
1 parent edbfef4e66
commit 3e4b6ce9e1
17 files changed
+1584 -139

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
# AGENTS.md
This file captures repo-specific context discovered while working on this branch.
## Repo overview
- Project: OldCombatMechanics (Bukkit/Paper plugin)
- Branch context: working from `kotlin-tests` branch
- Build tool: Gradle (wrapper currently at 9.2.1)
- JDKs used locally: 8, 11, 17, 25
## Integration test harness (Kotlin)
- Integration tests live in `src/integrationTest/kotlin` and are packaged into `OldCombatMechanics-<version>-tests.jar`.
- Entrypoint plugin class: `kernitus.plugin.OldCombatMechanics.OCMTestMain`.
- Tests run inside a real Paper server started by the Gradle `run-paper` plugin.
- Matrix task: `integrationTest` depends on `integrationTestMatrix` which runs per-version tasks like:
- `integrationTest1_19_2`, `integrationTest1_21_11`, `integrationTest1_12`, `integrationTest1_9`
- Test result handoff:
- `OCMTestMain` writes `plugins/OldCombatMechanicsTest/test-results.txt` containing `PASS` or `FAIL`.
- Gradle `checkTestResults<version>` fails build if file missing, or content is not `PASS`.
## Version matrix + Java selection
- Config is in `build.gradle.kts`:
- `integrationTestVersions` from property or defaults.
- `requiredJavaVersion` selects Java based on version.
- Pre-1.13 versions use `integrationTestJavaVersionLegacyPre13` (default 8).
- Modern versions use Java 25 if `>=1.20.5` else Java 17.
- Legacy vanilla jar cache:
- `downloadVanilla<version>` task downloads Mojang server jars for <=1.12 and writes `run/<version>/cache/mojang_<version>.jar`.
## Kotlin test runner split (Java 11+ vs Java 8)
- Kotest 6 (Java 11+) is used for 1.19.2 and 1.21.11.
- Kotest runner class: `kernitus.plugin.OldCombatMechanics.KotestRunner`
- Project config: `KotestProjectConfig`
- Java 8 uses a separate runner:
- `kernitus.plugin.OldCombatMechanics.LegacyTestRunner`
- Currently a **smoke test** only (verifies plugin enabled + `WeaponDamages` loaded).
- This is a placeholder and **not a full integration test**.
## Fake player implementation notes
- Primary fake player implementation is `src/integrationTest/kotlin/kernitus/plugin/OldCombatMechanics/FakePlayer.kt`.
- It relies on `xyz.jpenilla:reflection-remapper` to map modern NMS names.
- On legacy servers (1.12), reflection remapper mappings are unavailable; code falls back to `ReflectionRemapper.noop()`.
- As-is, `FakePlayer` uses modern NMS class names (`net.minecraft.server.MinecraftServer`, etc.).
- This fails on 1.12 which uses versioned NMS (`net.minecraft.server.v1_12_R1.*`).
- 1.12 requires a dedicated fake player path or version-aware class mapping.
## Java 8 compatibility work
- Java 8 compatibility backports already done in main code (records/pattern matching removed).
- Java 8-incompatible APIs replaced:
- `Stream.toList()` -> `collect(Collectors.toList())`
- `Set.of`/`List.of` -> `Collections.unmodifiableSet(new HashSet<>(Arrays.asList(...)))` etc.
- Added missing import in `PotionTypeCompat` for `PotionData`.
- Build config sets `options.release.set(8)` for Java, Kotlin `jvmTarget = 1.8`.
## Dependency / build updates
- Kotlin: 2.3.0
- Kotest: 6.0.7
- run-paper plugin: 3.0.2
- Hangar publish plugin: 0.1.4
- Other deps updated (bstats, netty, BSON, XSeries, authlib, reflection-remapper, adventure).
- JSR-305 added for `javax.annotation.Nullable` (compileOnly).
## Current failing area
- Paper 1.12 integration tests **do not run real fake player tests yet**.
- Desired fix: add proper 1.12 fake player implementation (e.g., version-specific NMS path like `v1_12_R1`).
- Example 1.12 fake player implementation provided by user (NMS, PlayerList manipulation, packet send, etc.).
- We should integrate a conditional path in `FakePlayer` for 1.12 using versioned NMS classes or an alternate helper.
## Local test commands
- Run full matrix:
- `./gradlew integrationTest`
- Change matrix:
- `./gradlew integrationTest -PintegrationTestVersions=1.19.2,1.21.11,1.12`
- Set Java toolchain paths (example):
- `ORG_GRADLE_JAVA_INSTALLATIONS_PATHS=/path/to/jdk8:/path/to/jdk17:/path/to/jdk25 ./gradlew integrationTest`
## Notes
- Paper 1.12 sometimes fails to download legacy vanilla jar from old Mojang endpoint. The custom `downloadVanilla` task fixes that by using the v2 manifest.
- 1.21.11 servers log hostname warnings and Unsafe warnings; tests still pass.
- 1.9 integration tests are currently on hold per user request.
- Reflection should be used only as a fallback (performance cost); prefer direct API/code paths when available.
+87 -2
View File
@@ -5,14 +5,19 @@
*/
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
import groovy.json.JsonSlurper
import io.papermc.hangarpublishplugin.model.Platforms
import org.gradle.api.Action
import org.gradle.api.attributes.java.TargetJvmVersion
import org.gradle.api.file.FileCopyDetails
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
import xyz.jpenilla.runpaper.task.RunServer
import java.io.ByteArrayOutputStream
import java.io.Serializable
import java.net.URI
import java.security.MessageDigest
import java.nio.file.Files
val paperVersion: List<String> = (property("gameVersions") as String)
.split(",")
@@ -85,6 +90,13 @@ configurations {
}
}
configurations.named("compileClasspath") {
attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 17)
}
configurations.named("integrationTestCompileClasspath") {
attributes.attribute(TargetJvmVersion.TARGET_JVM_VERSION_ATTRIBUTE, 17)
}
dependencies {
implementation("org.bstats:bstats-bukkit:3.1.0")
// Shaded in by Bukkit
@@ -141,6 +153,7 @@ tasks.named<Copy>("processResources") {
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.release.set(8)
}
val shadowJarTask = tasks.named<ShadowJar>("shadowJar") {
@@ -171,7 +184,7 @@ kotlin {
}
tasks.withType<KotlinCompile>().configureEach {
compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
compilerOptions.jvmTarget.set(JvmTarget.JVM_1_8)
}
val integrationTestJarTask = tasks.register<ShadowJar>("integrationTestJar") {
@@ -198,7 +211,7 @@ val integrationTestJarTask = tasks.register<ShadowJar>("integrationTestJar") {
val integrationTestMinecraftVersion =
(findProperty("integrationTestMinecraftVersion") as String?) ?: "1.19.2"
val defaultIntegrationTestVersions = listOf(integrationTestMinecraftVersion, "1.21.11")
val defaultIntegrationTestVersions = listOf(integrationTestMinecraftVersion, "1.21.11", "1.12")
.distinct()
val integrationTestVersions: List<String> = (findProperty("integrationTestVersions") as String?)
@@ -210,6 +223,8 @@ val integrationTestVersions: List<String> = (findProperty("integrationTestVersio
val integrationTestJavaVersionLegacy =
(findProperty("integrationTestJavaVersionLegacy") as String?)?.toInt() ?: 17
val integrationTestJavaVersionLegacyPre13 =
(findProperty("integrationTestJavaVersionLegacyPre13") as String?)?.toInt() ?: 8
val integrationTestJavaVersionModern =
(findProperty("integrationTestJavaVersionModern") as String?)?.toInt() ?: 25
@@ -221,6 +236,11 @@ fun parseMinecraftVersion(version: String): Triple<Int, Int, Int> {
return Triple(major, minor, patch)
}
fun needsLegacyVanillaJar(version: String): Boolean {
val (major, minor, _) = parseMinecraftVersion(version)
return major == 1 && minor <= 12
}
fun requiresModernJava(version: String): Boolean {
val (major, minor, patch) = parseMinecraftVersion(version)
if (major > 1) return true
@@ -229,9 +249,23 @@ fun requiresModernJava(version: String): Boolean {
}
fun requiredJavaVersion(version: String): Int {
if (needsLegacyVanillaJar(version)) return integrationTestJavaVersionLegacyPre13
return if (requiresModernJava(version)) integrationTestJavaVersionModern else integrationTestJavaVersionLegacy
}
fun sha1(file: File): String {
val digest = MessageDigest.getInstance("SHA-1")
file.inputStream().use { input ->
val buffer = ByteArray(8192)
while (true) {
val read = input.read(buffer)
if (read <= 0) break
digest.update(buffer, 0, read)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
tasks.register("integrationTest") {
group = "verification"
description = "Runs integration tests against all configured Paper versions."
@@ -258,6 +292,7 @@ for (version in integrationTestVersions) {
val suffix = versionTaskSuffix(version)
val runDir = file("run/$version")
val resultFile = runDir.resolve("plugins/OldCombatMechanicsTest/test-results.txt")
val vanillaCacheFile = runDir.resolve("cache/mojang_${version}.jar")
val writePropsTask = tasks.register<WriteProperties>("writeProperties${suffix}") {
encoding = "UTF-8"
@@ -265,8 +300,58 @@ for (version in integrationTestVersions) {
destinationFile.set(runDir.resolve("server.properties"))
}
val downloadVanillaTask = if (needsLegacyVanillaJar(version)) {
tasks.register("downloadVanilla${suffix}") {
outputs.file(vanillaCacheFile)
notCompatibleWithConfigurationCache("Downloads vanilla server jar for legacy Paper versions.")
doLast {
val slurper = JsonSlurper()
val manifestText = URI("https://piston-meta.mojang.com/mc/game/version_manifest_v2.json")
.toURL()
.readText()
val manifest = slurper.parseText(manifestText) as Map<*, *>
val versionsList = manifest["versions"] as List<Map<*, *>>
val versionEntry = versionsList.firstOrNull { it["id"] == version }
?: throw GradleException("Minecraft version '$version' not found in Mojang manifest.")
val versionUrl = versionEntry["url"] as String
val versionMetaText = URI(versionUrl).toURL().readText()
val versionMeta = slurper.parseText(versionMetaText) as Map<*, *>
val downloads = versionMeta["downloads"] as Map<*, *>
val serverInfo = downloads["server"] as Map<*, *>
val serverUrl = serverInfo["url"] as String
val serverSha1 = serverInfo["sha1"] as String
if (vanillaCacheFile.exists()) {
val existingSha1 = sha1(vanillaCacheFile)
if (existingSha1.equals(serverSha1, ignoreCase = true)) {
return@doLast
}
} else {
vanillaCacheFile.parentFile.mkdirs()
}
val tmpFile = Files.createTempFile("mc-server-${version}-", ".jar").toFile()
URI(serverUrl).toURL().openStream().use { input ->
tmpFile.outputStream().use { output -> input.copyTo(output) }
}
val downloadedSha1 = sha1(tmpFile)
if (!downloadedSha1.equals(serverSha1, ignoreCase = true)) {
tmpFile.delete()
throw GradleException(
"Downloaded Minecraft server jar hash mismatch for $version. Expected $serverSha1, got $downloadedSha1."
)
}
tmpFile.copyTo(vanillaCacheFile, overwrite = true)
tmpFile.delete()
}
}
} else {
null
}
val runServerTask = tasks.register<RunServer>("runServer${suffix}") {
dependsOn(writePropsTask)
downloadVanillaTask?.let { dependsOn(it) }
runDirectory.set(runDir)
minecraftVersion(version)
jvmArgs("-Dcom.mojang.eula.agree=true")
+2 -2
View File
@@ -4,5 +4,5 @@
gameVersions=1.21, 1.20.6, 1.20.5, 1.20.4, 1.20.3, 1.20.2, 1.20.1, 1.20, 1.19.4, 1.19.3, 1.19.2, 1.19.1, 1.19, 1.18.2, 1.18.1, 1.18, 1.17, 1.16, 1.15, 1.14, 1.13, 1.12, 1.11, 1.10, 1.9
# Enable configuration cache.
org.gradle.configuration-cache=true
# Disable configuration cache (run-paper tasks are not compatible).
org.gradle.configuration-cache=false
-2
View File
@@ -3,6 +3,4 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
rootProject.name = "OldCombatMechanics"
@@ -11,12 +11,14 @@ import io.netty.channel.ChannelInboundHandlerAdapter
import io.netty.channel.embedded.EmbeddedChannel
import kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.entity.Entity
import org.bukkit.entity.Player
import org.bukkit.event.player.AsyncPlayerPreLoginEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerPreLoginEvent
import org.bukkit.event.player.PlayerQuitEvent
import org.bukkit.plugin.java.JavaPlugin
import xyz.jpenilla.reflectionremapper.ReflectionRemapper
@@ -33,7 +35,14 @@ class FakePlayer(private val plugin: JavaPlugin) {
private var bukkitPlayer: Player? = null
private var networkConnection: Any? = null
private var usedPlaceNewPlayer: Boolean = false
private val reflectionRemapper = ReflectionRemapper.forReobfMappingsInPaperJar()
private val isLegacy = !Reflector.versionIsNewerOrEqualTo(1, 13, 0)
private val legacyImpl: LegacyFakePlayer12? = if (isLegacy) LegacyFakePlayer12(plugin, uuid, name) else null
private val reflectionRemapper: ReflectionRemapper = try {
ReflectionRemapper.forReobfMappingsInPaperJar()
} catch (e: Throwable) {
plugin.logger.warning("Reflection mappings not found; using no-op remapper for legacy server.")
ReflectionRemapper.noop()
}
// Helper function to load NMS classes using the appropriate class loader and remap names
fun getNMSClass(name: String): Class<*> {
@@ -50,6 +59,12 @@ class FakePlayer(private val plugin: JavaPlugin) {
}
fun spawn(location: Location) {
if (isLegacy) {
legacyImpl!!.spawn(location)
serverPlayer = legacyImpl.entityPlayer ?: throw IllegalStateException("Legacy entity player not created.")
bukkitPlayer = legacyImpl.bukkitPlayer
return
}
plugin.logger.info("Spawn: Starting")
// Get the NMS WorldServer (ServerLevel) from the Bukkit world
@@ -563,6 +578,9 @@ class FakePlayer(private val plugin: JavaPlugin) {
}
fun getConnection(serverPlayer: Any): Any {
if (isLegacy) {
return legacyImpl!!.getConnection(serverPlayer)
}
val entityPlayerClass = serverPlayer.javaClass
val connectionFieldName = reflectionRemapper.remapFieldName(entityPlayerClass, "connection")
val connectionField = Reflector.getField(entityPlayerClass, connectionFieldName)
@@ -570,6 +588,10 @@ class FakePlayer(private val plugin: JavaPlugin) {
}
fun removePlayer() {
if (isLegacy) {
legacyImpl!!.removePlayer()
return
}
// Fire PlayerQuitEvent
val quitMessage = "§e$name left the game"
val playerQuitEvent = PlayerQuitEvent(bukkitPlayer!!, quitMessage)
@@ -684,6 +706,16 @@ class FakePlayer(private val plugin: JavaPlugin) {
}
fun doBlocking() {
if (isLegacy) {
// Legacy blocking: ensure sword in hand + shield in offhand, then raise offhand.
bukkitPlayer!!.inventory.setItemInMainHand(org.bukkit.inventory.ItemStack(Material.DIAMOND_SWORD))
if (bukkitPlayer!!.inventory.itemInOffHand.type != Material.SHIELD) {
bukkitPlayer!!.inventory.setItemInOffHand(org.bukkit.inventory.ItemStack(Material.SHIELD))
}
bukkitPlayer!!.updateInventory()
legacyImpl?.startUsingOffhand()
return
}
bukkitPlayer!!.inventory.setItemInMainHand(org.bukkit.inventory.ItemStack(Material.SHIELD))
val livingEntityClass = getNMSClass("net.minecraft.world.entity.LivingEntity")
@@ -0,0 +1,103 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.KotestInternal
import io.kotest.core.config.AbstractProjectConfig
import io.kotest.core.extensions.TestCaseExtension
import io.kotest.core.spec.IsolationMode
import io.kotest.core.test.TestCase
import io.kotest.engine.TestEngineLauncher
import io.kotest.engine.concurrency.SpecExecutionMode
import io.kotest.engine.concurrency.TestExecutionMode
import io.kotest.engine.listener.AbstractTestEngineListener
import io.kotest.engine.test.TestResult
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import org.bukkit.Bukkit
import org.bukkit.plugin.java.JavaPlugin
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.coroutineContext
object KotestProjectConfig : AbstractProjectConfig() {
override val isolationMode = IsolationMode.SingleInstance
override val specExecutionMode = SpecExecutionMode.Sequential
override val testExecutionMode = TestExecutionMode.Sequential
}
class BukkitMainThreadDispatcher(private val plugin: JavaPlugin) : CoroutineDispatcher() {
override fun dispatch(context: CoroutineContext, block: Runnable) {
Bukkit.getScheduler().runTask(plugin, block)
}
}
class MainThreadDispatcherExtension(private val plugin: JavaPlugin) : TestCaseExtension {
override suspend fun intercept(testCase: TestCase, execute: suspend (TestCase) -> TestResult): TestResult {
val dispatcher = BukkitMainThreadDispatcher(plugin)
val newContext = coroutineContext + dispatcher
return withContext(newContext) {
execute(testCase)
}
}
}
object KotestRunner {
@OptIn(KotestInternal::class)
@JvmStatic
fun run(plugin: JavaPlugin) {
// Schedule test asynchronously to avoid deadlock.
Bukkit.getScheduler().runTaskAsynchronously(plugin, Runnable {
try {
var hasFailures = false
val listener = object : AbstractTestEngineListener() {
override suspend fun testFinished(testCase: TestCase, result: TestResult) {
val testName = testCase.name.name
when {
result.isSuccess -> plugin.logger.info("Test '$testName' passed.")
result.isIgnored -> plugin.logger.info("Test '$testName' was ignored.")
result.isFailure || result.isError -> {
val message = result.errorOrNull?.message
?: result.reasonOrNull
?: "Unknown failure"
plugin.logger.warning("Test '$testName' failed with exception: $message")
hasFailures = true
}
}
}
override suspend fun testStarted(testCase: TestCase) {
plugin.logger.info("Starting test '${testCase.name.name}'")
}
override suspend fun engineFinished(t: List<Throwable>) {
val success = t.isEmpty() && !hasFailures
if (success) {
plugin.logger.info("All tests passed successfully.")
} else {
plugin.logger.warning("Test run completed with failures.")
t.forEach { throwable ->
plugin.logger.warning("Engine error: ${throwable.message}")
}
}
TestResultWriter.writeAndShutdown(plugin, success)
}
}
TestEngineLauncher()
.withListener(listener)
.withProjectConfig(KotestProjectConfig)
.withClasses(InGameTesterIntegrationTest::class)
.launch()
} catch (e: Throwable) {
plugin.logger.severe("Failed to execute Kotest runner: ${e.message}")
TestResultWriter.writeAndShutdown(plugin, false, e)
}
})
}
}
@@ -0,0 +1,451 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import com.mojang.authlib.GameProfile
import io.netty.channel.ChannelInboundHandlerAdapter
import io.netty.channel.embedded.EmbeddedChannel
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.entity.Player
import org.bukkit.event.player.AsyncPlayerPreLoginEvent
import org.bukkit.event.player.PlayerJoinEvent
import org.bukkit.event.player.PlayerPreLoginEvent
import org.bukkit.event.player.PlayerQuitEvent
import org.bukkit.plugin.java.JavaPlugin
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.net.InetAddress
import java.util.UUID
internal class LegacyFakePlayer12(
private val plugin: JavaPlugin,
val uuid: UUID,
val name: String
) {
private val cbVersion: String = Bukkit.getServer().javaClass.`package`.name.substringAfterLast('.')
var entityPlayer: Any? = null
private set
var bukkitPlayer: Player? = null
private set
private fun nmsClass(simpleName: String): Class<*> =
Class.forName("net.minecraft.server.$cbVersion.$simpleName")
private fun craftClass(simpleName: String): Class<*> =
Class.forName("org.bukkit.craftbukkit.$cbVersion.$simpleName")
fun spawn(location: Location) {
plugin.logger.info("Spawn: Starting (legacy $cbVersion)")
val world = location.world ?: throw IllegalArgumentException("Location has no world!")
val craftWorld = craftClass("CraftWorld").cast(world)
val worldServer = craftWorld.javaClass.getMethod("getHandle").invoke(craftWorld)
val craftServer = craftClass("CraftServer").cast(Bukkit.getServer())
val minecraftServer = craftServer.javaClass.getMethod("getServer").invoke(craftServer)
val entityPlayer = createEntityPlayer(minecraftServer, worldServer)
this.entityPlayer = entityPlayer
val bukkitPlayer = entityPlayer.javaClass.getMethod("getBukkitEntity").invoke(entityPlayer) as Player
this.bukkitPlayer = bukkitPlayer
firePreLoginEvents()
val playerList = minecraftServer.javaClass.getMethod("getPlayerList").invoke(minecraftServer)
invokeMethodIfExists(playerList, "a", entityPlayer)
setPositionRotation(entityPlayer, location)
setDataWatcherFlags(entityPlayer)
spawnInWorld(entityPlayer, worldServer)
setGameMode(entityPlayer, worldServer)
setupConnection(entityPlayer, minecraftServer)
addToPlayerChunkMap(worldServer, entityPlayer)
addToPlayerList(playerList, entityPlayer)
updatePlayerListMaps(playerList, entityPlayer)
val joinMessage = "§e$name joined the game"
val joinEvent = PlayerJoinEvent(bukkitPlayer, joinMessage)
Bukkit.getPluginManager().callEvent(joinEvent)
broadcastMessage(joinEvent.joinMessage)
sendSpawnPackets(entityPlayer)
addEntityToWorld(worldServer, entityPlayer)
Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, Runnable {
runCatching { invokeMethod(entityPlayer, "playerTick") }
}, 1L, 1L)
plugin.logger.info("Spawn: completed successfully (legacy)")
}
fun removePlayer() {
val entityPlayer = entityPlayer ?: return
val bukkitPlayer = bukkitPlayer ?: return
val craftServer = craftClass("CraftServer").cast(Bukkit.getServer())
val minecraftServer = craftServer.javaClass.getMethod("getServer").invoke(craftServer)
val playerList = minecraftServer.javaClass.getMethod("getPlayerList").invoke(minecraftServer)
val quitMessage = "§e$name left the game"
val quitEvent = PlayerQuitEvent(bukkitPlayer, quitMessage)
Bukkit.getPluginManager().callEvent(quitEvent)
val worldServer = getWorldServer(entityPlayer)
val playerChunkMap = getPlayerChunkMap(worldServer)
invokeMethodIfExists(playerChunkMap, "removePlayer", entityPlayer)
invokeMethodIfExists(worldServer, "removeEntity", entityPlayer)
bukkitPlayer.kickPlayer(quitMessage)
removeFromPlayerList(playerList, entityPlayer)
removeFromPlayerMaps(playerList, entityPlayer)
sendRemovePackets(entityPlayer)
invokeMethodIfExists(playerList, "savePlayerFile", entityPlayer)
}
fun startUsingOffhand() {
val entityPlayer = entityPlayer ?: return
val enumHandClass = nmsClass("EnumHand")
val offHand = enumValue(enumHandClass, "OFF_HAND")
val candidateNames = arrayOf("c", "a", "startUsingItem")
for (name in candidateNames) {
val method = findMethod(entityPlayer.javaClass, name, arrayOf(offHand), ignoreMissing = true)
if (method != null) {
method.invoke(entityPlayer, offHand)
return
}
}
val fallback = entityPlayer.javaClass.methods.firstOrNull { method ->
method.parameterTypes.size == 1 && method.parameterTypes[0] == enumHandClass
}
fallback?.invoke(entityPlayer, offHand)
}
fun getConnection(serverPlayer: Any): Any {
val connectionField = findField(serverPlayer.javaClass, "playerConnection")
?: throw NoSuchFieldException("playerConnection not found on ${serverPlayer.javaClass.name}")
return connectionField.get(serverPlayer)
}
private fun createEntityPlayer(minecraftServer: Any, worldServer: Any): Any {
val entityPlayerClass = nmsClass("EntityPlayer")
val playerInteractManagerClass = nmsClass("PlayerInteractManager")
val pimCtor = playerInteractManagerClass.constructors.firstOrNull { ctor ->
ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(worldServer.javaClass)
} ?: playerInteractManagerClass.constructors.first()
val playerInteractManager = pimCtor.newInstance(worldServer)
val gameProfile = GameProfile(uuid, name)
val ctor = entityPlayerClass.constructors.firstOrNull { ctor ->
val params = ctor.parameterTypes
params.size == 4 &&
params[0].isAssignableFrom(minecraftServer.javaClass) &&
params[1].isAssignableFrom(worldServer.javaClass) &&
params[2] == GameProfile::class.java &&
params[3].isAssignableFrom(playerInteractManagerClass)
} ?: entityPlayerClass.constructors.first()
return ctor.newInstance(minecraftServer, worldServer, gameProfile, playerInteractManager)
}
private fun firePreLoginEvents() {
try {
val address = InetAddress.getByName("127.0.0.1")
val asyncPreLogin = AsyncPlayerPreLoginEvent(name, address, uuid)
val preLogin = PlayerPreLoginEvent(name, address, uuid)
Thread { Bukkit.getPluginManager().callEvent(asyncPreLogin) }.start()
Bukkit.getPluginManager().callEvent(preLogin)
} catch (e: Exception) {
plugin.logger.warning("Failed to fire pre-login events: ${e.message}")
}
}
private fun setPositionRotation(entityPlayer: Any, location: Location) {
val method = entityPlayer.javaClass.getMethod(
"setPositionRotation",
Double::class.javaPrimitiveType,
Double::class.javaPrimitiveType,
Double::class.javaPrimitiveType,
Float::class.javaPrimitiveType,
Float::class.javaPrimitiveType
)
method.invoke(
entityPlayer,
location.x,
location.y,
location.z,
location.yaw,
location.pitch
)
}
private fun setDataWatcherFlags(entityPlayer: Any) {
runCatching {
val dataWatcher = invokeMethod(entityPlayer, "getDataWatcher")
val dataWatcherRegistryClass = nmsClass("DataWatcherRegistry")
val serializer = dataWatcherRegistryClass.getField("a").get(null)
val dataWatcherObject = invokeMethod(serializer, "a", 13)
val setMethod = dataWatcher.javaClass.methods.firstOrNull { it.name == "set" && it.parameterCount == 2 }
setMethod?.invoke(dataWatcher, dataWatcherObject, 127.toByte())
}
}
private fun spawnInWorld(entityPlayer: Any, worldServer: Any) {
invokeMethodIfExists(entityPlayer, "spawnIn", worldServer)
val playerInteractManager = findField(entityPlayer.javaClass, "playerInteractManager")?.get(entityPlayer)
if (playerInteractManager != null) {
invokeMethodIfExists(playerInteractManager, "a", worldServer)
}
}
private fun setGameMode(entityPlayer: Any, worldServer: Any) {
val playerInteractManager = findField(entityPlayer.javaClass, "playerInteractManager")?.get(entityPlayer) ?: return
val enumGamemodeClass = nmsClass("EnumGamemode")
val gameMode = Bukkit.getServer().defaultGameMode
val enumValue = enumValue(enumGamemodeClass, gameMode)
invokeMethodIfExists(playerInteractManager, "b", enumValue)
}
private fun setupConnection(entityPlayer: Any, minecraftServer: Any) {
val networkManagerClass = nmsClass("NetworkManager")
val enumProtocolDirectionClass = nmsClass("EnumProtocolDirection")
val serverbound = enumValue(enumProtocolDirectionClass, "SERVERBOUND")
val networkManager = networkManagerClass
.getConstructor(enumProtocolDirectionClass)
.newInstance(serverbound)
val playerConnectionClass = nmsClass("PlayerConnection")
val pcCtor = playerConnectionClass.constructors.firstOrNull { ctor ->
val params = ctor.parameterTypes
params.size == 3 &&
params[0].isAssignableFrom(minecraftServer.javaClass) &&
params[1].isAssignableFrom(networkManagerClass) &&
params[2].isAssignableFrom(entityPlayer.javaClass)
} ?: playerConnectionClass.constructors.first()
val playerConnection = pcCtor.newInstance(minecraftServer, networkManager, entityPlayer)
setFieldValue(entityPlayer, "playerConnection", playerConnection)
val channel = EmbeddedChannel(ChannelInboundHandlerAdapter())
setFieldValue(networkManager, "channel", channel)
runCatching { channel.close() }
}
private fun addToPlayerChunkMap(worldServer: Any, entityPlayer: Any) {
val playerChunkMap = getPlayerChunkMap(worldServer)
invokeMethodIfExists(playerChunkMap, "addPlayer", entityPlayer)
}
private fun addToPlayerList(playerList: Any, entityPlayer: Any) {
runCatching {
val playersField = findField(playerList.javaClass, "players")
@Suppress("UNCHECKED_CAST") val players = playersField?.get(playerList) as? MutableCollection<Any>
players?.add(entityPlayer)
}
}
private fun updatePlayerListMaps(playerList: Any, entityPlayer: Any) {
runCatching {
val byUuidField = findField(playerList.javaClass, "j")
@Suppress("UNCHECKED_CAST") val byUuid = byUuidField?.get(playerList) as? MutableMap<Any, Any>
byUuid?.put(uuid, entityPlayer)
}
runCatching {
val byNameField = findField(playerList.javaClass, "playersByName")
@Suppress("UNCHECKED_CAST") val byName = byNameField?.get(playerList) as? MutableMap<Any, Any>
byName?.put(name, entityPlayer)
}
}
private fun sendSpawnPackets(entityPlayer: Any) {
val packetPlayOutPlayerInfo = nmsClass("PacketPlayOutPlayerInfo")
val actionClass = nmsClass("PacketPlayOutPlayerInfo\$EnumPlayerInfoAction")
val addAction = enumValue(actionClass, "ADD_PLAYER")
val entityArray = java.lang.reflect.Array.newInstance(entityPlayer.javaClass, 1)
java.lang.reflect.Array.set(entityArray, 0, entityPlayer)
val infoPacket = packetPlayOutPlayerInfo
.getConstructor(actionClass, entityArray.javaClass)
.newInstance(addAction, entityArray)
val namedSpawnPacket = createSingleArgPacket("PacketPlayOutNamedEntitySpawn", entityPlayer)
val craftPlayerClass = craftClass("entity.CraftPlayer")
for (player in Bukkit.getOnlinePlayers()) {
val craftPlayer = craftPlayerClass.cast(player)
val handle = craftPlayerClass.getMethod("getHandle").invoke(craftPlayer)
val connection = findField(handle.javaClass, "playerConnection")?.get(handle) ?: continue
sendPacket(connection, infoPacket)
if (namedSpawnPacket != null) {
sendPacket(connection, namedSpawnPacket)
}
}
}
private fun sendRemovePackets(entityPlayer: Any) {
val packetDestroy = createEntityDestroyPacket(entityPlayer)
val packetInfo = createPlayerInfoPacket("REMOVE_PLAYER", entityPlayer)
val craftPlayerClass = craftClass("entity.CraftPlayer")
for (player in Bukkit.getOnlinePlayers()) {
val craftPlayer = craftPlayerClass.cast(player)
val handle = craftPlayerClass.getMethod("getHandle").invoke(craftPlayer)
val connection = findField(handle.javaClass, "playerConnection")?.get(handle) ?: continue
if (packetDestroy != null) sendPacket(connection, packetDestroy)
if (packetInfo != null) sendPacket(connection, packetInfo)
}
}
private fun createPlayerInfoPacket(actionName: String, entityPlayer: Any): Any? {
return runCatching {
val packetPlayOutPlayerInfo = nmsClass("PacketPlayOutPlayerInfo")
val actionClass = nmsClass("PacketPlayOutPlayerInfo\$EnumPlayerInfoAction")
val action = enumValue(actionClass, actionName)
val entityArray = java.lang.reflect.Array.newInstance(entityPlayer.javaClass, 1)
java.lang.reflect.Array.set(entityArray, 0, entityPlayer)
packetPlayOutPlayerInfo
.getConstructor(actionClass, entityArray.javaClass)
.newInstance(action, entityArray)
}.getOrNull()
}
private fun createEntityDestroyPacket(entityPlayer: Any): Any? {
return runCatching {
val packetClass = nmsClass("PacketPlayOutEntityDestroy")
val getIdMethod = entityPlayer.javaClass.getMethod("getId")
val entityId = getIdMethod.invoke(entityPlayer) as Int
val ids = intArrayOf(entityId)
packetClass.getConstructor(IntArray::class.java).newInstance(ids)
}.getOrNull()
}
private fun createSingleArgPacket(className: String, entityPlayer: Any): Any? {
return runCatching {
val packetClass = nmsClass(className)
val ctor = packetClass.constructors.firstOrNull { ctor ->
ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(entityPlayer.javaClass)
} ?: packetClass.constructors.firstOrNull { ctor ->
ctor.parameterTypes.size == 1 && ctor.parameterTypes[0].isAssignableFrom(entityPlayer.javaClass.superclass)
}
ctor?.newInstance(entityPlayer)
}.getOrNull()
}
private fun addEntityToWorld(worldServer: Any, entityPlayer: Any) {
invokeMethodIfExists(worldServer, "addEntity", entityPlayer)
}
private fun getWorldServer(entityPlayer: Any): Any {
val worldField = findField(entityPlayer.javaClass, "world")
return worldField?.get(entityPlayer) ?: invokeMethod(entityPlayer, "getWorld")
}
private fun getPlayerChunkMap(worldServer: Any): Any {
val getPlayerChunkMap = worldServer.javaClass.methods.firstOrNull { it.name == "getPlayerChunkMap" }
return if (getPlayerChunkMap != null) {
getPlayerChunkMap.invoke(worldServer)
} else {
findField(worldServer.javaClass, "playerChunkMap")?.get(worldServer)
?: throw NoSuchFieldException("playerChunkMap not found on ${worldServer.javaClass.name}")
}
}
private fun removeFromPlayerList(playerList: Any, entityPlayer: Any) {
runCatching {
val playersField = findField(playerList.javaClass, "players")
@Suppress("UNCHECKED_CAST") val players = playersField?.get(playerList) as? MutableCollection<Any>
players?.remove(entityPlayer)
}
}
private fun removeFromPlayerMaps(playerList: Any, entityPlayer: Any) {
runCatching {
val byUuidField = findField(playerList.javaClass, "j")
@Suppress("UNCHECKED_CAST") val byUuid = byUuidField?.get(playerList) as? MutableMap<Any, Any>
byUuid?.remove(uuid)
}
runCatching {
val byNameField = findField(playerList.javaClass, "playersByName")
@Suppress("UNCHECKED_CAST") val byName = byNameField?.get(playerList) as? MutableMap<Any, Any>
byName?.remove(name)
}
}
private fun sendPacket(connection: Any, packet: Any) {
val packetClass = nmsClass("Packet")
val sendMethod = connection.javaClass.getMethod("sendPacket", packetClass)
sendMethod.invoke(connection, packet)
}
private fun broadcastMessage(message: String?) {
if (message.isNullOrEmpty()) return
for (player in Bukkit.getOnlinePlayers()) {
player.sendMessage(message)
}
}
private fun invokeMethod(target: Any, name: String, vararg args: Any?): Any {
val method = findMethod(target.javaClass, name, args)
?: throw NoSuchMethodException("Method '$name' not found on ${target.javaClass.name}")
return method.invoke(target, *args)
}
private fun invokeMethodIfExists(target: Any, name: String, vararg args: Any?) {
val method = findMethod(target.javaClass, name, args, ignoreMissing = true) ?: return
method.invoke(target, *args)
}
private fun findMethod(
clazz: Class<*>,
name: String,
args: Array<out Any?>,
ignoreMissing: Boolean = false
): Method? {
val candidates = (clazz.methods + clazz.declaredMethods).filter { it.name == name }
val method = candidates.firstOrNull { method ->
if (method.parameterTypes.size != args.size) return@firstOrNull false
method.parameterTypes.indices.all { idx ->
val arg = args[idx]
arg == null || method.parameterTypes[idx].isAssignableFrom(arg.javaClass)
}
} ?: candidates.firstOrNull { it.parameterTypes.size == args.size }
if (method == null && !ignoreMissing) {
throw NoSuchMethodException("Method '$name' not found on ${clazz.name}")
}
method?.isAccessible = true
return method
}
private fun findField(clazz: Class<*>, name: String): Field? {
var current: Class<*>? = clazz
while (current != null) {
runCatching {
val field = current.getDeclaredField(name)
field.isAccessible = true
return field
}
current = current.superclass
}
return null
}
private fun setFieldValue(target: Any, name: String, value: Any?) {
val field = findField(target.javaClass, name) ?: return
field.set(target, value)
}
private fun enumValue(enumClass: Class<*>, name: String): Any {
@Suppress("UNCHECKED_CAST")
val enumType = enumClass as Class<out Enum<*>>
return java.lang.Enum.valueOf(enumType, name)
}
private fun enumValue(enumClass: Class<*>, gameMode: GameMode): Any {
return enumValue(enumClass, gameMode.name)
}
}
@@ -0,0 +1,674 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import kernitus.plugin.OldCombatMechanics.module.ModuleGoldenApple
import kernitus.plugin.OldCombatMechanics.utilities.Config
import kernitus.plugin.OldCombatMechanics.utilities.damage.WeaponDamages
import kernitus.plugin.OldCombatMechanics.utilities.potions.PotionEffectTypeCompat
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.getPlayerData
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.setPlayerData
import kernitus.plugin.OldCombatMechanics.versions.materials.MaterialRegistry.ENCHANTED_GOLDEN_APPLE
import org.bukkit.Bukkit
import org.bukkit.GameMode
import org.bukkit.Location
import org.bukkit.Material
import org.bukkit.attribute.Attribute
import org.bukkit.block.BlockFace
import org.bukkit.entity.Player
import org.bukkit.event.block.Action
import org.bukkit.event.player.PlayerInteractEvent
import org.bukkit.event.player.PlayerItemConsumeEvent
import org.bukkit.inventory.CraftingInventory
import org.bukkit.inventory.EquipmentSlot
import org.bukkit.inventory.ItemStack
import org.bukkit.plugin.java.JavaPlugin
import org.bukkit.potion.PotionEffect
import org.bukkit.potion.PotionEffectType
import java.lang.reflect.Constructor
import java.util.logging.Level
object LegacyTestRunner {
@JvmStatic
fun run(plugin: JavaPlugin) {
plugin.logger.info("Running legacy integration tests (Java 8 runtime).")
Bukkit.getScheduler().runTask(plugin, Runnable {
LegacyTestSuite(plugin).start()
})
}
private class LegacyTestSuite(private val plugin: JavaPlugin) {
private var failures = 0
private var total = 0
private var delayTicks = 0L
private var meleeAttacker: FakePlayer? = null
private var meleeDefender: FakePlayer? = null
private var meleeAttackerPlayer: Player? = null
private var meleeDefenderPlayer: Player? = null
private var singleFake: FakePlayer? = null
private var singlePlayer: Player? = null
private var craftingInventory: CraftingInventory? = null
private val ocm = Bukkit.getPluginManager().getPlugin("OldCombatMechanics") as? OCMMain
private val module = ModuleGoldenApple.getInstance()
fun start() {
if (ocm == null || !ocm.isEnabled) {
fail("OldCombatMechanics plugin is not enabled.")
finish()
return
}
schedule("sanity") {
assertTrue(WeaponDamages.getDamage(Material.STONE_SWORD) > 0, "Weapon damages loaded")
}
spawnMeleePlayers()
schedule("melee attack") { runMeleeAttack() }
schedule(1, "melee assert") { assertMeleeAttack() }
schedule("remove melee players") { removeMeleePlayers() }
spawnSinglePlayer()
schedule("sword blocking") { runSwordBlockingTest() }
addGoldenAppleTests()
schedule("cleanup single player") { removeSinglePlayer() }
schedule("finish") { finish() }
}
private fun schedule(name: String, action: () -> Unit) = schedule(0, name, action)
private fun schedule(delay: Long, name: String, action: () -> Unit) {
delayTicks += delay
Bukkit.getScheduler().runTaskLater(plugin, Runnable {
try {
action()
} catch (e: Throwable) {
fail("$name threw ${e.javaClass.simpleName}: ${e.message}")
}
}, delayTicks)
}
private fun finish() {
val success = failures == 0
plugin.logger.info("Legacy test summary: passed ${total - failures}/$total")
TestResultWriter.writeAndShutdown(plugin, success)
}
private fun assertTrue(condition: Boolean, message: String) {
total++
if (condition) {
plugin.logger.info("PASS: $message")
} else {
fail(message)
}
}
private fun fail(message: String) {
failures++
plugin.logger.severe("FAIL: $message")
}
private fun spawnMeleePlayers() {
val world = Bukkit.getServer().getWorld("world")
if (world == null) {
fail("World 'world' is not loaded.")
return
}
val baseLocation = Location(world, 0.0, 100.0, 0.0)
meleeAttacker = FakePlayer(plugin).also { it.spawn(baseLocation.clone().add(2.0, 0.0, 0.0)) }
meleeDefender = FakePlayer(plugin).also { it.spawn(baseLocation.clone().add(0.0, 0.0, 2.0)) }
meleeAttackerPlayer = Bukkit.getPlayer(meleeAttacker!!.uuid)
meleeDefenderPlayer = Bukkit.getPlayer(meleeDefender!!.uuid)
if (meleeAttackerPlayer == null || meleeDefenderPlayer == null) {
fail("Failed to resolve melee players.")
return
}
preparePlayers(meleeAttackerPlayer!!, meleeDefenderPlayer!!)
applyModesets(meleeAttackerPlayer!!, meleeDefenderPlayer!!)
}
private fun runMeleeAttack() {
val attacker = meleeAttackerPlayer ?: return
val defender = meleeDefenderPlayer ?: return
attacker.inventory.setItemInMainHand(ItemStack(Material.STONE_SWORD))
defender.maximumNoDamageTicks = 0
val attackMethod = attacker.javaClass.methods.firstOrNull { method ->
method.name == "attack" &&
method.parameterTypes.size == 1 &&
method.parameterTypes[0].isAssignableFrom(defender.javaClass)
}
if (attackMethod != null) {
attackMethod.invoke(attacker, defender)
} else {
defender.damage(1.0, attacker)
}
}
private fun assertMeleeAttack() {
val attacker = meleeAttackerPlayer ?: return
@Suppress("DEPRECATION") // Deprecated API kept for older server compatibility in tests.
assertTrue(attacker.health == attacker.maxHealth, "Melee attack does not hurt attacker")
}
private fun removeMeleePlayers() {
meleeAttacker?.removePlayer()
meleeDefender?.removePlayer()
meleeAttacker = null
meleeDefender = null
meleeAttackerPlayer = null
meleeDefenderPlayer = null
}
private fun spawnSinglePlayer() {
val world = Bukkit.getServer().getWorld("world")
if (world == null) {
fail("World 'world' is not loaded.")
return
}
val location = Location(world, 0.0, 100.0, 0.0)
singleFake = FakePlayer(plugin).also { it.spawn(location) }
singlePlayer = Bukkit.getPlayer(singleFake!!.uuid)
if (singlePlayer == null) {
fail("Failed to resolve single player.")
return
}
preparePlayers(singlePlayer!!)
singlePlayer!!.isOp = true
}
private fun removeSinglePlayer() {
singleFake?.removePlayer()
singleFake = null
singlePlayer = null
}
private fun runSwordBlockingTest() {
val player = singlePlayer ?: return
resetPlayerState(player)
player.inventory.setItemInMainHand(ItemStack(Material.DIAMOND_SWORD))
callInteractEvent(player, Action.RIGHT_CLICK_AIR, player.inventory.itemInMainHand)
assertTrue(player.inventory.itemInOffHand.type == Material.SHIELD, "Sword blocking: shield in offhand")
singleFake?.doBlocking()
assertTrue(player.isBlocking || player.isHandRaised, "Sword blocking: player blocking or hand raised")
}
private fun addGoldenAppleTests() {
val player = singlePlayer ?: return
val snapshot = snapshotConfig()
val modesetSnapshot = snapshotModeset("new")
removeModuleFromModeset("new", "old-golden-apples")
schedule("gapple effects setup") {
resetPlayerState(player)
applyConfig(snapshot)
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsumeEvent(player, player.inventory.itemInMainHand)
}
schedule(2, "gapple effects assert") {
assertPotion(player.getPotionEffect(PotionEffectType.REGENERATION), 5 * 20, 1, "Gapple regen")
assertPotion(player.getPotionEffect(PotionEffectType.ABSORPTION), 120 * 20, 0, "Gapple absorption")
}
schedule("napple effects setup") {
resetPlayerState(player)
applyConfig(snapshot)
player.inventory.setItemInMainHand(ENCHANTED_GOLDEN_APPLE.newInstance())
callConsumeEvent(player, player.inventory.itemInMainHand)
}
schedule(2, "napple effects assert") {
assertPotion(player.getPotionEffect(PotionEffectType.REGENERATION), 30 * 20, 4, "Napple regen")
assertPotion(player.getPotionEffect(PotionEffectType.ABSORPTION), 120 * 20, 0, "Napple absorption")
assertPotion(player.getPotionEffect(PotionEffectTypeCompat.RESISTANCE.get()), 300 * 20, 0, "Napple resistance")
assertPotion(player.getPotionEffect(PotionEffectType.FIRE_RESISTANCE), 300 * 20, 0, "Napple fire resistance")
}
schedule("higher amplifier setup") {
resetPlayerState(player)
applyConfig(snapshot)
player.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 100, 0))
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsumeEvent(player, player.inventory.itemInMainHand)
}
schedule(4, "higher amplifier assert") {
assertPotion(player.getPotionEffect(PotionEffectType.REGENERATION), 5 * 20, 1, "Higher amplifier regen")
}
schedule("same amplifier setup") {
resetPlayerState(player)
applyConfig(snapshot)
player.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 50, 1))
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsumeEvent(player, player.inventory.itemInMainHand)
}
schedule(4, "same amplifier assert") {
assertPotion(player.getPotionEffect(PotionEffectType.REGENERATION), 5 * 20, 1, "Same amplifier regen")
}
schedule("cooldown normal setup") {
resetPlayerState(player)
applyConfig(snapshot)
ocm!!.config.set("old-golden-apples.cooldown.normal", 2)
module.reload()
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
val event1 = callConsumeEvent(player, player.inventory.itemInMainHand)
assertTrue(!event1.isCancelled, "Gapple cooldown: first consume allowed")
val event2 = callConsumeEvent(player, player.inventory.itemInMainHand)
assertTrue(event2.isCancelled, "Gapple cooldown: second consume blocked")
}
schedule(secondsToTicks(2) + 2, "cooldown normal assert") {
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
val event3 = callConsumeEvent(player, player.inventory.itemInMainHand)
assertTrue(!event3.isCancelled, "Gapple cooldown: expires")
applyConfig(snapshot)
}
schedule("shared cooldown setup") {
resetPlayerState(player)
applyConfig(snapshot)
ocm!!.config.set("old-golden-apples.cooldown.normal", 5)
ocm.config.set("old-golden-apples.cooldown.enchanted", 10)
ocm.config.set("old-golden-apples.cooldown.is-shared", true)
module.reload()
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
val gappleEvent = callConsumeEvent(player, player.inventory.itemInMainHand)
assertTrue(!gappleEvent.isCancelled, "Shared cooldown: gapple allowed")
player.inventory.setItemInMainHand(ENCHANTED_GOLDEN_APPLE.newInstance())
val nappleEvent = callConsumeEvent(player, player.inventory.itemInMainHand)
assertTrue(nappleEvent.isCancelled, "Shared cooldown: napple blocked")
applyConfig(snapshot)
}
schedule("non-shared cooldown setup") {
resetPlayerState(player)
applyConfig(snapshot)
ocm!!.config.set("old-golden-apples.cooldown.normal", 5)
ocm.config.set("old-golden-apples.cooldown.enchanted", 10)
ocm.config.set("old-golden-apples.cooldown.is-shared", false)
module.reload()
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
val gappleEvent = callConsumeEvent(player, player.inventory.itemInMainHand)
assertTrue(!gappleEvent.isCancelled, "Non-shared cooldown: gapple allowed")
player.inventory.setItemInMainHand(ENCHANTED_GOLDEN_APPLE.newInstance())
val nappleEvent = callConsumeEvent(player, player.inventory.itemInMainHand)
assertTrue(!nappleEvent.isCancelled, "Non-shared cooldown: napple allowed")
applyConfig(snapshot)
}
schedule(2, "module disabled via modeset setup") {
resetPlayerState(player)
applyConfig(snapshot)
ocm!!.config.set("old-golden-apples.golden-apple-effects.regeneration.duration", 99)
module.reload()
setModeset(player, "new")
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsumeEvent(player, player.inventory.itemInMainHand, applyVanillaEffects = true)
}
schedule(2, "module disabled via modeset assert") {
assertPotion(player.getPotionEffect(PotionEffectType.REGENERATION), 5 * 20, 1, "Modeset disables module")
setModeset(player, "old")
applyConfig(snapshot)
}
schedule("old-potion-effects disabled setup") {
resetPlayerState(player)
applyConfig(snapshot)
ocm!!.config.set("old-golden-apples.old-potion-effects", false)
ocm.config.set("old-golden-apples.golden-apple-effects.regeneration.duration", 99)
module.reload()
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsumeEvent(player, player.inventory.itemInMainHand, applyVanillaEffects = true)
}
schedule(2, "old-potion-effects disabled assert") {
assertPotion(player.getPotionEffect(PotionEffectType.REGENERATION), 5 * 20, 1, "Old potion effects disabled")
applyConfig(snapshot)
}
schedule("crafting enabled setup") {
resetPlayerState(player)
applyConfig(snapshot)
ocm!!.config.set("old-golden-apples.enchanted-golden-apple-crafting", true)
module.reload()
setModeset(player, "old")
craftingInventory = openCraftingInventory(player)
if (craftingInventory == null) {
fail("Crafting inventory not available")
return@schedule
}
prepareCraftingGrid(craftingInventory!!)
}
schedule(2, "crafting enabled assert") {
val inv = craftingInventory
if (inv == null) {
fail("Crafting inventory missing for enabled assert")
} else {
assertTrue(inv.result != null && ENCHANTED_GOLDEN_APPLE.isSame(inv.result!!), "Crafting enabled")
}
player.closeInventory()
craftingInventory = null
}
schedule("crafting disabled config setup") {
resetPlayerState(player)
applyConfig(snapshot)
ocm!!.config.set("old-golden-apples.enchanted-golden-apple-crafting", false)
module.reload()
setModeset(player, "old")
craftingInventory = openCraftingInventory(player)
if (craftingInventory == null) {
fail("Crafting inventory not available")
return@schedule
}
prepareCraftingGrid(craftingInventory!!)
}
schedule(2, "crafting disabled config assert") {
val inv = craftingInventory
if (inv == null) {
fail("Crafting inventory missing for disabled config assert")
} else {
assertTrue(inv.result == null, "Crafting disabled in config")
}
player.closeInventory()
craftingInventory = null
applyConfig(snapshot)
}
schedule("crafting disabled modeset setup") {
resetPlayerState(player)
applyConfig(snapshot)
ocm!!.config.set("old-golden-apples.enchanted-golden-apple-crafting", true)
module.reload()
setModeset(player, "new")
craftingInventory = openCraftingInventory(player)
if (craftingInventory == null) {
fail("Crafting inventory not available")
return@schedule
}
prepareCraftingGrid(craftingInventory!!)
}
schedule(2, "crafting disabled modeset assert") {
val inv = craftingInventory
if (inv == null) {
fail("Crafting inventory missing for disabled modeset assert")
} else {
assertTrue(inv.result == null, "Crafting disabled by modeset")
}
player.closeInventory()
craftingInventory = null
setModeset(player, "old")
applyConfig(snapshot)
}
schedule("no-conflict-mode setup") {
resetPlayerState(player)
applyConfig(snapshot)
ocm!!.config.set("old-golden-apples.enchanted-golden-apple-crafting", true)
ocm.config.set("old-golden-apples.no-conflict-mode", true)
module.reload()
setModeset(player, "new")
craftingInventory = openCraftingInventory(player)
if (craftingInventory == null) {
fail("Crafting inventory not available")
return@schedule
}
prepareCraftingGrid(craftingInventory!!)
}
schedule(2, "no-conflict-mode assert") {
val inv = craftingInventory
if (inv == null) {
fail("Crafting inventory missing for no-conflict assert")
} else {
assertTrue(inv.result != null && ENCHANTED_GOLDEN_APPLE.isSame(inv.result!!), "No-conflict mode allows crafting")
}
player.closeInventory()
craftingInventory = null
setModeset(player, "old")
applyConfig(snapshot)
}
schedule("getGappleCooldown setup") {
resetPlayerState(player)
applyConfig(snapshot)
ocm!!.config.set("old-golden-apples.cooldown.normal", 10)
module.reload()
player.inventory.setItemInMainHand(ItemStack(Material.GOLDEN_APPLE))
callConsumeEvent(player, player.inventory.itemInMainHand)
}
schedule(2, "getGappleCooldown assert 1") {
val cd = module.getGappleCooldown(player.uniqueId)
assertTrue(cd in 9..10, "getGappleCooldown initial")
}
schedule(secondsToTicks(5), "getGappleCooldown assert 2") {
val cd = module.getGappleCooldown(player.uniqueId)
assertTrue(cd in 4..5, "getGappleCooldown mid")
}
schedule(secondsToTicks(5), "getGappleCooldown assert 3") {
val cd = module.getGappleCooldown(player.uniqueId)
assertTrue(cd == 0L, "getGappleCooldown end")
applyConfig(snapshot)
}
schedule("getNappleCooldown setup") {
resetPlayerState(player)
applyConfig(snapshot)
ocm!!.config.set("old-golden-apples.cooldown.enchanted", 20)
module.reload()
player.inventory.setItemInMainHand(ENCHANTED_GOLDEN_APPLE.newInstance())
callConsumeEvent(player, player.inventory.itemInMainHand)
}
schedule(2, "getNappleCooldown assert 1") {
val cd = module.getNappleCooldown(player.uniqueId)
assertTrue(cd in 19..20, "getNappleCooldown initial")
}
schedule(secondsToTicks(10), "getNappleCooldown assert 2") {
val cd = module.getNappleCooldown(player.uniqueId)
assertTrue(cd in 9..10, "getNappleCooldown mid")
}
schedule(secondsToTicks(10), "getNappleCooldown assert 3") {
val cd = module.getNappleCooldown(player.uniqueId)
assertTrue(cd == 0L, "getNappleCooldown end")
applyConfig(snapshot)
}
schedule("restore modesets") {
restoreModeset("new", modesetSnapshot)
}
}
private fun snapshotConfig(): GappleConfigSnapshot {
val config = ocm!!.config
return GappleConfigSnapshot(
config.getBoolean("old-golden-apples.old-potion-effects"),
config.getLong("old-golden-apples.cooldown.normal"),
config.getLong("old-golden-apples.cooldown.enchanted"),
config.getBoolean("old-golden-apples.cooldown.is-shared"),
config.getBoolean("old-golden-apples.enchanted-golden-apple-crafting"),
config.getBoolean("old-golden-apples.no-conflict-mode"),
config.getInt("old-golden-apples.golden-apple-effects.regeneration.duration")
)
}
private fun applyConfig(snapshot: GappleConfigSnapshot) {
val config = ocm!!.config
config.set("old-golden-apples.old-potion-effects", snapshot.oldPotionEffects)
config.set("old-golden-apples.cooldown.normal", snapshot.normalCooldown)
config.set("old-golden-apples.cooldown.enchanted", snapshot.enchantedCooldown)
config.set("old-golden-apples.cooldown.is-shared", snapshot.sharedCooldown)
config.set("old-golden-apples.enchanted-golden-apple-crafting", snapshot.craftingEnabled)
config.set("old-golden-apples.no-conflict-mode", snapshot.noConflictMode)
config.set("old-golden-apples.golden-apple-effects.regeneration.duration", snapshot.regenDuration)
module.reload()
}
private fun resetPlayerState(player: Player) {
player.inventory.clear()
player.activePotionEffects.forEach { player.removePotionEffect(it.type) }
val maxAttribute = resolveMaxHealthAttribute()
val maxHealth = if (maxAttribute != null) {
player.getAttribute(maxAttribute)?.value ?: 20.0
} else {
20.0
}
player.health = maxHealth
player.foodLevel = 20
setModeset(player, "old")
player.isInvulnerable = false
}
private fun resolveMaxHealthAttribute(): Attribute? {
return runCatching { Attribute.valueOf("MAX_HEALTH") }
.getOrElse { runCatching { Attribute.valueOf("GENERIC_MAX_HEALTH") }.getOrNull() }
}
private fun preparePlayers(vararg players: Player) {
for (player in players) {
player.gameMode = GameMode.SURVIVAL
player.maximumNoDamageTicks = 20
player.noDamageTicks = 0
player.isInvulnerable = false
}
}
private fun applyModesets(attacker: Player, defender: Player) {
var playerData = getPlayerData(attacker.uniqueId)
playerData.setModesetForWorld(attacker.world.uid, "old")
setPlayerData(attacker.uniqueId, playerData)
playerData = getPlayerData(defender.uniqueId)
playerData.setModesetForWorld(defender.world.uid, "new")
setPlayerData(defender.uniqueId, playerData)
}
private fun setModeset(player: Player, modeset: String) {
val playerData = getPlayerData(player.uniqueId)
playerData.setModesetForWorld(player.world.uid, modeset)
setPlayerData(player.uniqueId, playerData)
}
private fun snapshotModeset(name: String): Set<String> {
val modesets = Config.getModesets()
val existing = modesets[name] ?: emptySet()
return HashSet(existing)
}
private fun removeModuleFromModeset(name: String, moduleName: String) {
val modesets = Config.getModesets()
val set = modesets[name] ?: return
set.remove(moduleName)
}
private fun restoreModeset(name: String, snapshot: Set<String>) {
val modesets = Config.getModesets()
val set = modesets[name] ?: return
set.clear()
set.addAll(snapshot)
}
private fun assertPotion(effect: PotionEffect?, duration: Int, amplifier: Int, label: String) {
assertTrue(effect != null, "$label present")
if (effect != null) {
val minDuration = duration - 3
assertTrue(effect.duration in minDuration..duration, "$label duration (got ${effect.duration})")
assertTrue(effect.amplifier == amplifier, "$label amplifier (got ${effect.amplifier})")
}
}
private fun prepareCraftingGrid(inventory: CraftingInventory) {
inventory.matrix = arrayOf(
ItemStack(Material.GOLD_BLOCK), ItemStack(Material.GOLD_BLOCK), ItemStack(Material.GOLD_BLOCK),
ItemStack(Material.GOLD_BLOCK), ItemStack(Material.APPLE), ItemStack(Material.GOLD_BLOCK),
ItemStack(Material.GOLD_BLOCK), ItemStack(Material.GOLD_BLOCK), ItemStack(Material.GOLD_BLOCK)
)
}
private fun openCraftingInventory(player: Player): CraftingInventory? {
return runCatching {
val method = player.javaClass.methods.firstOrNull { candidate ->
candidate.name == "openWorkbench" && candidate.parameterTypes.size == 2
} ?: return null
val view = method.invoke(player, null, true) ?: return null
val topInventory = view.javaClass.getMethod("getTopInventory").invoke(view)
topInventory as? CraftingInventory
}.onFailure { throwable ->
plugin.logger.log(Level.WARNING, "Failed to open crafting inventory: ${throwable.message}")
}.getOrNull()
}
private fun applyVanillaEffects(player: Player, item: ItemStack) {
val isEnchanted = ENCHANTED_GOLDEN_APPLE.isSame(item)
if (item.type != Material.GOLDEN_APPLE && !isEnchanted) return
if (isEnchanted) {
player.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 30 * 20, 1))
player.addPotionEffect(PotionEffect(PotionEffectType.ABSORPTION, 120 * 20, 3))
player.addPotionEffect(PotionEffect(PotionEffectTypeCompat.RESISTANCE.get(), 300 * 20, 0))
player.addPotionEffect(PotionEffect(PotionEffectType.FIRE_RESISTANCE, 300 * 20, 0))
} else {
player.addPotionEffect(PotionEffect(PotionEffectType.REGENERATION, 5 * 20, 1))
player.addPotionEffect(PotionEffect(PotionEffectType.ABSORPTION, 120 * 20, 0))
}
}
private fun callConsumeEvent(
player: Player,
item: ItemStack,
applyVanillaEffects: Boolean = false
): PlayerItemConsumeEvent {
val ctor = findConsumeCtor()
val event = if (ctor.parameterTypes.size == 3) {
ctor.newInstance(player, item, EquipmentSlot.HAND) as PlayerItemConsumeEvent
} else {
ctor.newInstance(player, item) as PlayerItemConsumeEvent
}
Bukkit.getPluginManager().callEvent(event)
if (!event.isCancelled && applyVanillaEffects) {
applyVanillaEffects(player, item)
}
return event
}
private fun callInteractEvent(player: Player, action: Action, item: ItemStack): PlayerInteractEvent {
val ctor = findInteractCtor()
val event = if (ctor.parameterTypes.size == 6) {
ctor.newInstance(player, action, item, null, BlockFace.SELF, EquipmentSlot.HAND) as PlayerInteractEvent
} else {
ctor.newInstance(player, action, item, null, BlockFace.SELF) as PlayerInteractEvent
}
Bukkit.getPluginManager().callEvent(event)
return event
}
private fun findConsumeCtor(): Constructor<*> {
val ctors = PlayerItemConsumeEvent::class.java.constructors
return ctors.firstOrNull { it.parameterTypes.size == 3 }
?: ctors.firstOrNull { it.parameterTypes.size == 2 }
?: ctors.first()
}
private fun findInteractCtor(): Constructor<*> {
val ctors = PlayerInteractEvent::class.java.constructors
return ctors.firstOrNull { it.parameterTypes.size == 6 }
?: ctors.firstOrNull { it.parameterTypes.size == 5 }
?: ctors.first()
}
private fun secondsToTicks(seconds: Long): Long = seconds * 20
}
private data class GappleConfigSnapshot(
val oldPotionEffects: Boolean,
val normalCooldown: Long,
val enchantedCooldown: Long,
val sharedCooldown: Boolean,
val craftingEnabled: Boolean,
val noConflictMode: Boolean,
val regenDuration: Int
)
}
@@ -6,115 +6,50 @@
package kernitus.plugin.OldCombatMechanics
import io.kotest.common.KotestInternal
import io.kotest.core.config.AbstractProjectConfig
import io.kotest.core.extensions.TestCaseExtension
import io.kotest.core.spec.IsolationMode
import io.kotest.core.test.TestCase
import io.kotest.engine.TestEngineLauncher
import io.kotest.engine.concurrency.SpecExecutionMode
import io.kotest.engine.concurrency.TestExecutionMode
import io.kotest.engine.listener.AbstractTestEngineListener
import io.kotest.engine.test.TestResult
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage
import org.bukkit.Bukkit
import org.bukkit.plugin.java.JavaPlugin
import java.io.File
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.coroutineContext
object KotestProjectConfig : AbstractProjectConfig() {
override val isolationMode = IsolationMode.SingleInstance
override val specExecutionMode = SpecExecutionMode.Sequential
override val testExecutionMode = TestExecutionMode.Sequential
}
class BukkitMainThreadDispatcher(private val plugin: JavaPlugin) : CoroutineDispatcher() {
override fun dispatch(context: CoroutineContext, block: Runnable) {
Bukkit.getScheduler().runTask(plugin, block)
}
}
class MainThreadDispatcherExtension(private val plugin: JavaPlugin) : TestCaseExtension {
override suspend fun intercept(testCase: TestCase, execute: suspend (TestCase) -> TestResult): TestResult {
val dispatcher = BukkitMainThreadDispatcher(plugin)
val newContext = coroutineContext + dispatcher
return withContext(newContext) {
execute(testCase)
}
}
}
import java.lang.reflect.InvocationTargetException
import java.util.logging.Level
class OCMTestMain : JavaPlugin() {
@OptIn(KotestInternal::class)
override fun onEnable() {
println("Enabled OCMTest plugin")
logger.info("Enabled OCMTest plugin")
// Initialise player data storage
val ocm = Bukkit.getPluginManager().getPlugin("OldCombatMechanics") as OCMMain
kernitus.plugin.OldCombatMechanics.utilities.storage.PlayerStorage.initialise(ocm)
PlayerStorage.initialise(ocm)
// Schedule test asynchronously to avoid deadlock
// This is because each test is run as a coroutine
Bukkit.getScheduler().runTaskAsynchronously(this, Runnable {
// Implement a test listener to capture test results
var hasFailures = false
val javaVersion = detectJavaVersion()
logger.info("Detected Java $javaVersion for integration tests")
val listener = object : AbstractTestEngineListener() {
// Override testFinished to capture each test result
override suspend fun testFinished(testCase: TestCase, result: TestResult) {
val testName = testCase.name.name
when {
result.isSuccess -> println("Test '$testName' passed.")
result.isIgnored -> println("Test '$testName' was ignored.")
result.isFailure || result.isError -> {
val message = result.errorOrNull?.message ?: result.reasonOrNull ?: "Unknown failure"
println("Test '$testName' failed with exception: $message")
hasFailures = true
}
}
}
if (javaVersion >= 11) {
runKotest()
} else {
LegacyTestRunner.run(this)
}
}
// Optionally override testStarted if you want to log when a test starts
override suspend fun testStarted(testCase: TestCase) {
println("Starting test '${testCase.name.name}'")
}
private fun runKotest() {
try {
val runnerClass = Class.forName("kernitus.plugin.OldCombatMechanics.KotestRunner")
val runMethod = runnerClass.getMethod("run", JavaPlugin::class.java)
runMethod.invoke(null, this)
} catch (e: InvocationTargetException) {
logger.log(Level.SEVERE, "Failed to launch Kotest runner.", e.targetException ?: e)
TestResultWriter.writeAndShutdown(this, false)
} catch (e: Throwable) {
logger.log(Level.SEVERE, "Failed to launch Kotest runner.", e)
TestResultWriter.writeAndShutdown(this, false)
}
}
// Override engineFinished to print a summary after all tests
override suspend fun engineFinished(t: List<Throwable>) {
val success = t.isEmpty() && !hasFailures
if (success) {
println("All tests passed successfully.")
} else {
println("Test run completed with failures.")
t.forEach { throwable ->
println("Engine error: ${throwable.message}")
}
}
// Write the result to a file for the Gradle build to check
try {
val resultFile = File(this@OCMTestMain.dataFolder, "test-results.txt")
resultFile.parentFile.mkdirs() // Ensure the directory exists
resultFile.writeText(if (success) "PASS" else "FAIL")
println("Test result written to ${resultFile.absolutePath}")
} catch (e: Exception) {
println("Failed to write test results file: ${e.message}")
}
Bukkit.shutdown()
}
}
// Run tests using TestEngineLauncher with Spec instances
TestEngineLauncher()
.withListener(listener)
.withProjectConfig(KotestProjectConfig)
.withClasses(InGameTesterIntegrationTest::class)
.launch()
})
private fun detectJavaVersion(): Int {
val version = System.getProperty("java.specification.version") ?: return 0
return if (version.startsWith("1.")) {
version.substringAfter("1.").toIntOrNull() ?: 0
} else {
version.toIntOrNull() ?: 0
}
}
}
@@ -0,0 +1,32 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package kernitus.plugin.OldCombatMechanics
import org.bukkit.Bukkit
import org.bukkit.plugin.java.JavaPlugin
import java.io.File
import java.util.logging.Level
object TestResultWriter {
@JvmStatic
fun writeAndShutdown(plugin: JavaPlugin, success: Boolean, error: Throwable? = null) {
try {
val resultFile = File(plugin.dataFolder, "test-results.txt")
resultFile.parentFile.mkdirs()
resultFile.writeText(if (success) "PASS" else "FAIL")
plugin.logger.info("Test result written to ${resultFile.absolutePath}")
} catch (e: Exception) {
plugin.logger.log(Level.SEVERE, "Failed to write test results file.", e)
}
if (error != null) {
plugin.logger.log(Level.SEVERE, "Integration tests failed.", error)
}
Bukkit.shutdown()
}
}
@@ -57,8 +57,9 @@ public class ModuleFishingKnockback extends OCMModule {
final FishHook hook = (FishHook) hookEntity;
if (!(hook.getShooter() instanceof Player rodder))
if (!(hook.getShooter() instanceof Player))
return;
final Player rodder = (Player) hook.getShooter();
if (!isEnabled(rodder))
return;
@@ -66,8 +67,9 @@ public class ModuleFishingKnockback extends OCMModule {
if (hitEntity == null)
return; // If no entity was hit
if (!(hitEntity instanceof LivingEntity livingEntity))
if (!(hitEntity instanceof LivingEntity))
return;
final LivingEntity livingEntity = (LivingEntity) hitEntity;
if (!knockbackNonPlayerEntities && !(hitEntity instanceof Player))
return;
@@ -110,7 +110,9 @@ public class ModuleGoldenApple extends OCMModule {
// something up.
if (ENCHANTED_GOLDEN_APPLE.isSame(item)) {
final HumanEntity player = e.getView().getPlayer();
final List<HumanEntity> viewers = e.getInventory().getViewers();
if (viewers.isEmpty()) return;
final HumanEntity player = viewers.get(0);
if (isSettingEnabled("no-conflict-mode"))
return;
@@ -120,6 +122,8 @@ public class ModuleGoldenApple extends OCMModule {
}
}
// Intentionally avoid InventoryView#getPlayer() to prevent class/interface mismatch on 1.12.
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onItemConsume(PlayerItemConsumeEvent e) {
final Player player = e.getPlayer();
@@ -197,6 +201,8 @@ public class ModuleGoldenApple extends OCMModule {
}
private void applyEffects(LivingEntity target, List<PotionEffect> newEffects) {
final boolean forceOverride = !kernitus.plugin.OldCombatMechanics.utilities.reflection.Reflector
.versionIsNewerOrEqualTo(1, 13, 0);
for (PotionEffect newEffect : newEffects) {
// Find the existing effect of the same type with the highest amplifier
final PotionEffect highestExistingEffect = target.getActivePotionEffects().stream()
@@ -207,13 +213,21 @@ public class ModuleGoldenApple extends OCMModule {
if (highestExistingEffect != null) {
// If the new effect has a higher amplifier, apply it
if (newEffect.getAmplifier() > highestExistingEffect.getAmplifier()) {
target.addPotionEffect(newEffect);
if (forceOverride) {
target.addPotionEffect(newEffect, true);
} else {
target.addPotionEffect(newEffect);
}
}
// If the amplifiers are the same and the new effect has a longer duration,
// refresh the duration
else if (newEffect.getAmplifier() == highestExistingEffect.getAmplifier() &&
newEffect.getDuration() > highestExistingEffect.getDuration()) {
target.addPotionEffect(newEffect);
if (forceOverride) {
target.addPotionEffect(newEffect, true);
} else {
target.addPotionEffect(newEffect);
}
}
// If the new effect has a lower amplifier or shorter/equal duration, do nothing
} else {
@@ -311,12 +325,22 @@ public class ModuleGoldenApple extends OCMModule {
}
}
private record Cooldown(long normal, long enchanted, boolean sharedCooldown) {
private static class Cooldown {
private final long normal;
private final long enchanted;
private final boolean sharedCooldown;
private Cooldown(long normal, long enchanted, boolean sharedCooldown) {
this.normal = normal;
this.enchanted = enchanted;
this.sharedCooldown = sharedCooldown;
}
private long getCooldownForItem(ItemStack item) {
return ENCHANTED_GOLDEN_APPLE.isSame(item) ? enchanted : normal;
}
boolean isOnCooldown(ItemStack item, LastEaten lastEaten) {
private boolean isOnCooldown(ItemStack item, LastEaten lastEaten) {
return (sharedCooldown ? lastEaten.getNewestEatTime() : lastEaten.getForItem(item))
.map(it -> ChronoUnit.SECONDS.between(it, Instant.now()))
.map(it -> it < getCooldownForItem(item))
@@ -48,7 +48,9 @@ public class ModuleOldArmourDurability extends OCMModule {
final List<ItemStack> armour = explosionDamaged.get(uuid);
// ItemStack.equals() checks material, durability and quantity to make sure nothing changed in the meantime
// We're checking all the pieces this way just in case they're wearing two helmets or something strange
final List<ItemStack> matchedPieces = armour.stream().filter(piece -> piece.equals(item)).toList();
final List<ItemStack> matchedPieces = armour.stream()
.filter(piece -> piece.equals(item))
.collect(Collectors.toList());
armour.removeAll(matchedPieces);
debug("Item matched explosion, ignoring...", player);
if (!matchedPieces.isEmpty()) return;
@@ -26,28 +26,34 @@ import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.bukkit.potion.PotionType;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Allows configurable potion effect durations.
*/
public class ModuleOldPotionEffects extends OCMModule {
private static final Set<PotionTypeCompat> EXCLUDED_POTION_TYPES = Set.of(
// Base potions without any effect
new PotionTypeCompat("AWKWARD"),
new PotionTypeCompat("MUNDANE"),
new PotionTypeCompat("THICK"),
new PotionTypeCompat("WATER"),
// Instant potions with no further effects
new PotionTypeCompat("HARMING"),
new PotionTypeCompat("STRONG_HARMING"),
new PotionTypeCompat("HEALING"),
new PotionTypeCompat("STRONG_HEALING"),
// This type doesn't exist anymore >1.20.5, is specially handled in compat class
new PotionTypeCompat("UNCRAFTABLE")
private static final Set<PotionTypeCompat> EXCLUDED_POTION_TYPES = Collections.unmodifiableSet(
new HashSet<>(Arrays.asList(
// Base potions without any effect
new PotionTypeCompat("AWKWARD"),
new PotionTypeCompat("MUNDANE"),
new PotionTypeCompat("THICK"),
new PotionTypeCompat("WATER"),
// Instant potions with no further effects
new PotionTypeCompat("HARMING"),
new PotionTypeCompat("STRONG_HARMING"),
new PotionTypeCompat("HEALING"),
new PotionTypeCompat("STRONG_HEALING"),
// This type doesn't exist anymore >1.20.5, is specially handled in compat class
new PotionTypeCompat("UNCRAFTABLE")
))
);
private Map<PotionTypeCompat, PotionDurations> durations;
@@ -136,9 +142,11 @@ public class ModuleOldPotionEffects extends OCMModule {
List<PotionEffectType> potionEffects;
final PotionType potionType = potionTypeCompat.getType();
try {
potionEffects = potionType.getPotionEffects().stream().map(PotionEffect::getType).toList();
potionEffects = potionType.getPotionEffects().stream()
.map(PotionEffect::getType)
.collect(Collectors.toList());
} catch (NoSuchMethodError e) {
potionEffects = List.of(potionType.getEffectType());
potionEffects = Collections.singletonList(potionType.getEffectType());
}
for (PotionEffectType effectType : potionEffects) {
@@ -157,15 +157,17 @@ public class ModuleSwordBlocking extends OCMModule {
@EventHandler(priority = EventPriority.HIGHEST)
public void onInventoryClick(InventoryClickEvent e) {
if (e.getWhoClicked() instanceof Player player) {
if (areItemsStored(player.getUniqueId())) {
final ItemStack cursor = e.getCursor();
final ItemStack current = e.getCurrentItem();
if (cursor != null && cursor.getType() == Material.SHIELD ||
current != null && current.getType() == Material.SHIELD) {
e.setCancelled(true);
restore(player, true);
}
if (!(e.getWhoClicked() instanceof Player)) {
return;
}
final Player player = (Player) e.getWhoClicked();
if (areItemsStored(player.getUniqueId())) {
final ItemStack cursor = e.getCursor();
final ItemStack current = e.getCurrentItem();
if (cursor != null && cursor.getType() == Material.SHIELD ||
current != null && current.getType() == Material.SHIELD) {
e.setCancelled(true);
restore(player, true);
}
}
}
@@ -8,5 +8,20 @@ package kernitus.plugin.OldCombatMechanics.utilities.potions;
/**
* Hold information on duration of drinkable & splash version of a potion type
*/
public record PotionDurations(int drinkable, int splash) {
public final class PotionDurations {
private final int drinkable;
private final int splash;
public PotionDurations(int drinkable, int splash) {
this.drinkable = drinkable;
this.splash = splash;
}
public int drinkable() {
return drinkable;
}
public int splash() {
return splash;
}
}
@@ -7,6 +7,7 @@
package kernitus.plugin.OldCombatMechanics.utilities.potions;
import org.bukkit.inventory.meta.PotionMeta;
import org.bukkit.potion.PotionData;
import org.bukkit.potion.PotionType;
import org.jetbrains.annotations.Nullable;
@@ -170,7 +171,7 @@ public class PotionTypeCompat {
return new PotionTypeCompat("UNCRAFTABLE");
return new PotionTypeCompat(potionType.name());
} catch (NoSuchMethodError e) {
final var potionData = potionMeta.getBasePotionData();
final PotionData potionData = potionMeta.getBasePotionData();
final PotionType potionType = potionData.getType();
return new PotionTypeCompat(potionType.name(), potionData.isUpgraded(), potionData.isExtended());
}