diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 6ef6214..bcd23eb 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -21,10 +21,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - name: Set up JDK 21 + - name: Set up JDK 25 uses: actions/setup-java@v3 with: - java-version: '21' + java-version: '25' distribution: 'temurin' - name: Setup Gradle diff --git a/README.md b/README.md index 91519d3..054570d 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # ⏳ Advanced PlayTime Plugin [![Build Status](https://github.com/imDMK/AdvancedPlayTime/actions/workflows/gradle.yml/badge.svg)](https://github.com/imDMK/AdvancedPlayTime/actions/workflows/gradle.yml) -![JDK](https://img.shields.io/badge/JDK-1.21-blue.svg) -![Supported versions](https://img.shields.io/badge/Minecraft-1.21--1.21.11-green.svg) +![JDK](https://img.shields.io/badge/JDK-25-blue.svg) +![Supported versions](https://img.shields.io/badge/Minecraft-1.21--26.2-green.svg) [![SpigotMC](https://img.shields.io/badge/SpigotMC-yellow.svg)](https://www.spigotmc.org/resources/%E2%8F%B0%EF%B8%8F-advancedplaytime-1-21-1-21-10.130458/) [![Modrinth](https://img.shields.io/badge/Modrinth-1bd96a.svg)](https://modrinth.com/plugin/advancedplaytime) [![bStats](https://img.shields.io/badge/bStats-00695c)](https://bstats.org/plugin/bukkit/PlayTime/19362) @@ -13,6 +13,10 @@ --- +### 📦 Requirements +- **Java 25** or newer on the server. +- **Spigot / Paper 1.21 – 26.2**. + ### ✨ Key Features - 🧠 **Highly optimized** – Zero-lag performance, even on large servers. - 🎨 **Fully customizable GUIs** – Design the look and feel to fit your server's style. diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 2cd8bba..e32b6af 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -9,6 +9,7 @@ repositories { dependencies { implementation("com.gradleup.shadow:shadow-gradle-plugin:9.6.1") implementation("net.minecrell:plugin-yml:0.6.0") + implementation("org.ow2.asm:asm:9.10.1") } sourceSets { diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 04c9a4c..e445c89 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,7 +1,17 @@ object Versions { + /** Java release the plugin is compiled for and requires at runtime. */ + const val JAVA = 25 + + /** Spigot API the plugin is compiled against (newest supported server). */ const val SPIGOT_API = "26.2-R0.1-SNAPSHOT" + /** Oldest supported Spigot API, verified by the `compileJavaSpigotMin` task. */ + const val SPIGOT_API_MIN = "1.21-R0.1-SNAPSHOT" + + /** Value of `api-version` in plugin.yml. */ + const val SPIGOT_API_VERSION = "1.21" + const val PLACEHOLDER_API = "2.12.3" const val PANDA_DI = "1.8.0" diff --git a/buildSrc/src/main/kotlin/playtime-java.gradle.kts b/buildSrc/src/main/kotlin/playtime-java.gradle.kts index d85f432..3336939 100644 --- a/buildSrc/src/main/kotlin/playtime-java.gradle.kts +++ b/buildSrc/src/main/kotlin/playtime-java.gradle.kts @@ -6,11 +6,11 @@ group = "com.github.imdmk" version = "3.0.2" java { - toolchain.languageVersion.set(JavaLanguageVersion.of(21)) + toolchain.languageVersion.set(JavaLanguageVersion.of(Versions.JAVA)) } -tasks.compileJava { +tasks.withType().configureEach { options.compilerArgs = listOf("-Xlint:deprecation", "-parameters") options.encoding = "UTF-8" - options.release = 21 -} \ No newline at end of file + options.release.set(Versions.JAVA) +} diff --git a/buildSrc/src/main/kotlin/playtime-spigot-compat.gradle.kts b/buildSrc/src/main/kotlin/playtime-spigot-compat.gradle.kts new file mode 100644 index 0000000..06e9145 --- /dev/null +++ b/buildSrc/src/main/kotlin/playtime-spigot-compat.gradle.kts @@ -0,0 +1,328 @@ +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.FieldVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type +import java.util.zip.ZipFile + +plugins { + `java-library` +} + +/** + * Keeps the plugin usable on the whole advertised server range. + * + * The shipped artifact is built against the newest Spigot API, so two things can + * silently break on the oldest supported server: sources may reference API that did + * not exist yet, and call sites may be emitted against a type whose shape changed + * (`org.bukkit.Sound`, for instance, was an enum in 1.21 and is an interface today). + * + * `compileJavaSpigotMin` catches the first case, `checkSpigotBinaryCompatibility` the + * second. Both run as part of `check`. + */ +val spigotMinApi: Configuration = configurations.create("spigotMinApi") { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + spigotMinApi("org.spigotmc:spigot-api:${Versions.SPIGOT_API_MIN}") +} + +/** A single method/field reference emitted into one of our own classes. */ +data class MemberReference( + val owner: String, + val name: String, + val descriptor: String, + val field: Boolean, + val viaInterface: Boolean +) + +/** Everything relevant we know about one class of the reference API. */ +data class ApiClass( + val name: String, + val isInterface: Boolean, + val superName: String?, + val interfaces: List, + val methods: Set, + val fields: Set +) + +abstract class CheckSpigotBinaryCompatibilityTask : DefaultTask() { + + private val bukkitPackage = "org/bukkit/" + + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val classDirectories: ConfigurableFileCollection + + @get:InputFiles + @get:Classpath + abstract val minimumApi: ConfigurableFileCollection + + @get:Input + abstract val minimumApiVersion: Property + + @get:OutputFile + abstract val report: RegularFileProperty + + @TaskAction + fun check() { + + val api = readApi() + val classReferences = sortedSetOf() + val memberReferences = mutableSetOf() + + readOwnClasses(classReferences, memberReferences) + + val problems = mutableListOf() + + for (reference in classReferences) { + if (!api.containsKey(reference)) { + problems += "unknown type $reference" + } + } + + for (reference in memberReferences.sortedBy { "${it.owner}#${it.name}${it.descriptor}" }) { + + val owner = api[reference.owner] + + if (owner == null) { + problems += "unknown type ${reference.owner}" + continue + } + + if (!hasMember(api, reference.owner, reference)) { + problems += "unknown ${if (reference.field) "field" else "method"} " + + "${reference.owner}#${reference.name}${if (reference.field) "" else reference.descriptor}" + continue + } + + if (!reference.field && reference.viaInterface != owner.isInterface) { + problems += "${reference.owner} is a ${if (owner.isInterface) "interface" else "class"} on " + + "${minimumApiVersion.get()}, but ${reference.name}${reference.descriptor} is compiled as " + + "${if (reference.viaInterface) "invokeinterface" else "invokevirtual/invokestatic"} — " + + "this throws IncompatibleClassChangeError at runtime" + } + } + + val distinct = problems.distinct() + + val file = report.get().asFile + file.parentFile.mkdirs() + file.writeText( + buildString { + appendLine("Spigot API baseline: ${minimumApiVersion.get()}") + appendLine("types referenced: ${classReferences.size}, members referenced: ${memberReferences.size}") + appendLine() + if (distinct.isEmpty()) appendLine("OK") else distinct.forEach { appendLine(it) } + } + ) + + if (distinct.isNotEmpty()) { + throw GradleException( + distinct.joinToString( + prefix = "Not binary compatible with Spigot API ${minimumApiVersion.get()}:\n - ", + separator = "\n - " + ) + ) + } + } + + private fun readApi(): Map { + + val api = mutableMapOf() + + minimumApi.filter { it.isFile }.forEach { jar -> + ZipFile(jar).use { zip -> + zip.entries().asSequence() + .filter { it.name.endsWith(".class") } + .forEach { entry -> + val reader = zip.getInputStream(entry).use { ClassReader(it.readBytes()) } + val methods = mutableSetOf() + val fields = mutableSetOf() + + reader.accept(object : ClassVisitor(Opcodes.ASM9) { + + override fun visitMethod( + access: Int, + name: String, + descriptor: String, + signature: String?, + exceptions: Array? + ): MethodVisitor? { + methods += "$name$descriptor" + return null + } + + override fun visitField( + access: Int, + name: String, + descriptor: String, + signature: String?, + value: Any? + ): FieldVisitor? { + fields += name + return null + } + }, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + + api[reader.className] = ApiClass( + name = reader.className, + isInterface = reader.access and Opcodes.ACC_INTERFACE != 0, + superName = reader.superName, + interfaces = reader.interfaces.toList(), + methods = methods, + fields = fields + ) + } + } + } + + return api + } + + private fun readOwnClasses( + classReferences: MutableSet, + memberReferences: MutableSet + ) { + + fun collectTypes(descriptor: String) { + Regex("L(org/bukkit/[^;<]+);").findAll(descriptor).forEach { classReferences += it.groupValues[1] } + } + + classDirectories.asFileTree.matching { include("**/*.class") }.forEach { file -> + + ClassReader(file.readBytes()).accept(object : ClassVisitor(Opcodes.ASM9) { + + override fun visitMethod( + access: Int, + name: String, + descriptor: String, + signature: String?, + exceptions: Array? + ): MethodVisitor { + + collectTypes(descriptor) + + return object : MethodVisitor(Opcodes.ASM9) { + + override fun visitTypeInsn(opcode: Int, type: String) { + if (type.startsWith(bukkitPackage)) classReferences += type + } + + override fun visitLdcInsn(value: Any?) { + if (value is Type && value.sort == Type.OBJECT && value.internalName.startsWith(bukkitPackage)) { + classReferences += value.internalName + } + } + + override fun visitFieldInsn(opcode: Int, owner: String, name: String, descriptor: String) { + collectTypes(descriptor) + if (owner.startsWith(bukkitPackage)) { + memberReferences += MemberReference(owner, name, descriptor, field = true, viaInterface = false) + } + } + + override fun visitMethodInsn( + opcode: Int, + owner: String, + name: String, + descriptor: String, + isInterface: Boolean + ) { + collectTypes(descriptor) + if (owner.startsWith(bukkitPackage)) { + memberReferences += MemberReference(owner, name, descriptor, field = false, viaInterface = isInterface) + } + } + } + } + + override fun visitField( + access: Int, + name: String, + descriptor: String, + signature: String?, + value: Any? + ): FieldVisitor? { + collectTypes(descriptor) + return null + } + }, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + } + } + + private fun hasMember(api: Map, owner: String, reference: MemberReference): Boolean { + + val current = api[owner] ?: return hasJdkMember(owner, reference) + + if (reference.field && reference.name in current.fields) return true + if (!reference.field && "${reference.name}${reference.descriptor}" in current.methods) return true + + val parents = buildList { + current.superName?.let { add(it) } + addAll(current.interfaces) + } + + return parents.any { hasMember(api, it, reference) } + } + + /** + * Bukkit types inherit from the JDK — `java.lang.Object` and, for the enums the + * older API still used, `java.lang.Enum`. Those supertypes are not in the Spigot + * jar, so resolve them against the running JVM instead of reporting them missing. + */ + private fun hasJdkMember(owner: String, reference: MemberReference): Boolean { + + if (!owner.startsWith("java/")) return false + + val type = runCatching { Class.forName(owner.replace('/', '.')) }.getOrNull() ?: return false + + if (reference.field) { + return generateSequence(type) { it.superclass } + .any { candidate -> candidate.declaredFields.any { it.name == reference.name } } + } + + return generateSequence(type) { it.superclass } + .any { candidate -> + candidate.declaredMethods.any { + it.name == reference.name && Type.getMethodDescriptor(it) == reference.descriptor + } + } + } +} + +val compileJavaSpigotMin = tasks.register("compileJavaSpigotMin") { + + description = "Compiles the main sources against Spigot API ${Versions.SPIGOT_API_MIN}." + group = LifecycleBasePlugin.VERIFICATION_GROUP + + val main = project.the()["main"] + + source(main.java) + + classpath = spigotMinApi + main.compileClasspath.filter { !it.name.startsWith("spigot-api-") } + + destinationDirectory.set(layout.buildDirectory.dir("classes/java/spigotMin")) +} + +val checkSpigotBinaryCompatibility = + tasks.register("checkSpigotBinaryCompatibility") { + + description = "Verifies that the Bukkit call sites emitted into this module resolve on " + + "Spigot API ${Versions.SPIGOT_API_MIN}." + group = LifecycleBasePlugin.VERIFICATION_GROUP + + classDirectories.from(project.the()["main"].output.classesDirs) + minimumApi.from(spigotMinApi) + minimumApiVersion.set(Versions.SPIGOT_API_MIN) + report.set(layout.buildDirectory.file("reports/spigot-compat/${project.name}.txt")) + + dependsOn(tasks.named("classes")) + } + +tasks.named("check") { + dependsOn(compileJavaSpigotMin, checkSpigotBinaryCompatibility) +} diff --git a/playtime-api/build.gradle.kts b/playtime-api/build.gradle.kts index 4ea3d08..723761b 100644 --- a/playtime-api/build.gradle.kts +++ b/playtime-api/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `playtime-java` `playtime-java-test` `playtime-repositories` + `playtime-spigot-compat` } dependencies { diff --git a/playtime-core/build.gradle.kts b/playtime-core/build.gradle.kts index b8f77c2..f7e9da0 100644 --- a/playtime-core/build.gradle.kts +++ b/playtime-core/build.gradle.kts @@ -3,6 +3,7 @@ plugins { `playtime-java-test` `playtime-repositories` `playtime-runtime-libraries` + `playtime-spigot-compat` } dependencies { diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/playtime/BukkitPlayTimeAdapter.java b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/playtime/BukkitPlayTimeAdapter.java index cd91df1..0ff34ee 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/playtime/BukkitPlayTimeAdapter.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/playtime/BukkitPlayTimeAdapter.java @@ -8,7 +8,6 @@ import org.bukkit.Server; import org.bukkit.Statistic; import org.bukkit.entity.Player; -import org.bukkit.event.player.PlayerTakeLecternBookEvent; import org.jetbrains.annotations.Nullable; import org.panda_lang.utilities.inject.annotations.Inject; diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/EnchantmentSerializer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/EnchantmentSerializer.java index fb6ab1f..81ed1cb 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/EnchantmentSerializer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/EnchantmentSerializer.java @@ -18,7 +18,7 @@ public boolean supports(@NotNull Class type) { @Override public void serialize(Enchantment enchantment, SerializationData data, @NotNull GenericsDeclaration generics) { - data.setValue(enchantment.getKeyOrThrow().toString(), String.class); + data.setValue(RegistryKeys.keyOf(enchantment).toString(), String.class); } @Override diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/RegistryKeys.java b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/RegistryKeys.java new file mode 100644 index 0000000..8766428 --- /dev/null +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/RegistryKeys.java @@ -0,0 +1,29 @@ +package com.github.imdmk.playtime.core.platform.serdes; + +import org.bukkit.Keyed; +import org.bukkit.NamespacedKey; + +/** + * Reads the {@link NamespacedKey} of a registry value in a way that stays binary + * compatible across the whole supported Spigot range. + * + *

{@code getKeyOrThrow()} only exists on {@code org.bukkit.registry.RegistryAware}, + * introduced long after 1.21, and types such as {@link org.bukkit.Sound} were plain + * enums back then instead of interfaces. Going through {@link Keyed} — an interface in + * every supported version — keeps the emitted call site valid on 1.21 and on 26.2. + */ +final class RegistryKeys { + + private RegistryKeys() { + } + + static NamespacedKey keyOf(Keyed keyed) { + NamespacedKey key = keyed.getKey(); + + if (key == null) { + throw new IllegalArgumentException("Registry value is not registered: " + keyed); + } + + return key; + } +} diff --git a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/SoundSerializer.java b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/SoundSerializer.java index 581729c..ae5da07 100644 --- a/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/SoundSerializer.java +++ b/playtime-core/src/main/java/com/github/imdmk/playtime/core/platform/serdes/SoundSerializer.java @@ -18,7 +18,7 @@ public boolean supports(@NotNull Class type) { @Override public void serialize(Sound sound, SerializationData data, @NotNull GenericsDeclaration generics) { - data.setValue(sound.getKeyOrThrow().toString(), String.class); + data.setValue(RegistryKeys.keyOf(sound).toString(), String.class); } @Override diff --git a/playtime-plugin/build.gradle.kts b/playtime-plugin/build.gradle.kts index 0f47309..4eb2380 100644 --- a/playtime-plugin/build.gradle.kts +++ b/playtime-plugin/build.gradle.kts @@ -2,6 +2,7 @@ plugins { `playtime-java` `playtime-repositories` `playtime-shadow` + `playtime-spigot-compat` id("xyz.jpenilla.run-paper") version "3.1.0" } @@ -20,7 +21,7 @@ playTimeShadow { pluginYml { name = "AdvancedPlayTime" version = project.version.toString() - apiVersion = "1.21" + apiVersion = Versions.SPIGOT_API_VERSION softDepend = listOf("PlaceholderAPI") main = "com.github.imdmk.playtime.plugin.PlayTimePlugin" author = "imDMK (dominiks8318@gmail.com)" @@ -29,7 +30,7 @@ playTimeShadow { } shadowJar { - archiveFileName.set("AdvancedPlayTime v${project.version} (MC 1.21.x).jar") + archiveFileName.set("AdvancedPlayTime v${project.version} (MC 1.21-26.2, Java ${Versions.JAVA}).jar") mergeServiceFiles() diff --git a/playtime-plugin/src/main/java/com/github/imdmk/playtime/plugin/PlayTimePlugin.java b/playtime-plugin/src/main/java/com/github/imdmk/playtime/plugin/PlayTimePlugin.java index a2f877c..50b3df0 100644 --- a/playtime-plugin/src/main/java/com/github/imdmk/playtime/plugin/PlayTimePlugin.java +++ b/playtime-plugin/src/main/java/com/github/imdmk/playtime/plugin/PlayTimePlugin.java @@ -2,8 +2,6 @@ import org.bukkit.plugin.java.JavaPlugin; -import java.util.logging.Logger; - public final class PlayTimePlugin extends JavaPlugin { private PlayTimeCoreWrapper wrapper;