From ed1e9a49328bc49593e49a3b63bb96f996c19172 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 16 Oct 2026 20:33:30 -0400 Subject: [PATCH] fix(creeper-fear): support Purpur 26.3 --- README.md | 6 +- build.gradle.kts | 61 +++++- src/main/resources/plugin.yml | 2 +- .../CreeperFearLifecycleNativeTest.java | 178 ++++++++++++++++++ .../creeperfear/PurpurDistributionTest.java | 41 +++- 5 files changed, 274 insertions(+), 14 deletions(-) create mode 100644 src/nativeTest/java/games/dmg/creeperfear/CreeperFearLifecycleNativeTest.java diff --git a/README.md b/README.md index 6e4abc0..1f9d6e0 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Purpur Creeper Fear -A Purpur 26.2 plugin that lets players earn six Creeper Aura ranks. Once unlocked, an aura prevents a creeper explosion within 25 blocks of the player from breaking blocks, whether or not the explosion hits the player. Players hit by the explosion receive rank-dependent damage. +A Purpur 26.3 plugin that lets players earn six Creeper Aura ranks. Once unlocked, an aura prevents a creeper explosion within 25 blocks of the player from breaking blocks, whether or not the explosion hits the player. Players hit by the explosion receive rank-dependent damage. ## Requirements -- Purpur 26.2 build 2618 +- Purpur 26.3 build 2651 - Java 25 New repositories/checkouts/artifacts use `purpur-creeper-fear`; runtime `CreeperFear`, Java entrypoint/packages, commands/permissions and `plugins/CreeperFear/player-progress.sqlite3` are unchanged. The SQLite driver remains bundled. Replace the old JAR rather than installing both distributions. Old tags/assets are retained, and the v1.3.0 download was verified byte-for-byte after renaming. New releases no longer support Java 17. @@ -36,6 +36,8 @@ cp build/libs/purpur-creeper-fear-0.1.0-SNAPSHOT.jar /path/to/server/plugins/ Restart the server after copying the JAR. The plugin creates its configuration and SQLite database under `plugins/CreeperFear/`. +The full verification command compiles against Purpur API `26.3.build.2651-stable`, downloads and SHA-256 verifies the exact Purpur 26.3 build-2651 runtime, and loads, enables, and disables the packaged entrypoint on that runtime. It also covers rank progression, damage attribution, SQLite schema/path, the bundled JDBC driver and service descriptor, Java 25 bytecode, plugin metadata, and API/server/ProtocolLib/test exclusions. This automated native boundary is not a live-server or real-client test. + ## Commands | Command | Description | Permission | diff --git a/build.gradle.kts b/build.gradle.kts index 3c89d8a..554eb16 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import java.net.URI +import java.security.MessageDigest + plugins { java } @@ -23,10 +26,10 @@ tasks.withType().configureEach { } dependencies { - compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") + compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable") implementation("org.xerial:sqlite-jdbc:3.50.3.0") - testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") + testImplementation("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable") testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.mockito:mockito-core:5.18.0") @@ -40,6 +43,60 @@ tasks.test { systemProperty("distribution.version", project.version.toString()) } +val nativeRuntime = layout.buildDirectory.dir("native-runtime") +val downloadNativeRuntime = tasks.register("downloadNativeRuntime") { + val launcher = nativeRuntime.map { it.file("purpur-26.3-2651.jar") } + outputs.file(launcher) + doLast { + val file = launcher.get().asFile + file.parentFile.mkdirs() + val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/download") + .toURL().openConnection() + connection.connectTimeout = 30_000 + connection.readTimeout = 120_000 + val bytes = connection.getInputStream().use { it.readBytes() } + val digest = MessageDigest.getInstance("SHA-256") + .digest(bytes).joinToString("") { "%02x".format(it) } + check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") { + "Purpur 2651 checksum mismatch" + } + file.writeBytes(bytes) + } +} +val prepareNativeRuntime = tasks.register("prepareNativeRuntime") { + dependsOn(downloadNativeRuntime) + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) } + classpath = files(nativeRuntime.map { it.file("purpur-26.3-2651.jar") }) + mainClass = "io.papermc.paperclip.Main" + jvmArgs("-Dpaperclip.patchonly=true") + workingDir(nativeRuntime) + outputs.dir(nativeRuntime.map { it.dir("versions") }) + outputs.dir(nativeRuntime.map { it.dir("libraries") }) +} +val nativeTest = sourceSets.create("nativeTest") +dependencies { + add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4")) + add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter") + add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher") +} +val nativeRuntimeJars = files(fileTree(nativeRuntime) { + include("versions/**/*.jar", "libraries/**/*.jar") +}).builtBy(prepareNativeRuntime) +nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars +nativeTest.runtimeClasspath += sourceSets.main.get().runtimeClasspath + nativeRuntimeJars +val nativeLifecycleTest = tasks.register("nativeLifecycleTest") { + description = "Loads, enables, and disables the packaged plugin on the pinned Purpur runtime" + dependsOn(tasks.jar) + testClassesDirs = nativeTest.output.classesDirs + classpath = nativeTest.runtimeClasspath + useJUnitPlatform() + maxHeapSize = "1G" + workingDir(nativeRuntime) + systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath) + systemProperty("distribution.version", project.version.toString()) +} +tasks.check { dependsOn(nativeLifecycleTest) } + val pluginVersion = version tasks.processResources { diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 6e823df..800ab0e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,7 +1,7 @@ name: CreeperFear version: '${version}' main: games.dmg.creeperfear.CreeperFearPlugin -api-version: '26.2' +api-version: '26.3' author: dmg.games description: Unlock Creeper Aura ranks by defeating creepers. commands: diff --git a/src/nativeTest/java/games/dmg/creeperfear/CreeperFearLifecycleNativeTest.java b/src/nativeTest/java/games/dmg/creeperfear/CreeperFearLifecycleNativeTest.java new file mode 100644 index 0000000..79d4f6a --- /dev/null +++ b/src/nativeTest/java/games/dmg/creeperfear/CreeperFearLifecycleNativeTest.java @@ -0,0 +1,178 @@ +package games.dmg.creeperfear; + +import static org.junit.jupiter.api.Assertions.*; + +import io.papermc.paper.plugin.configuration.PluginMeta; +import io.papermc.paper.plugin.provider.classloader.ConfiguredPluginClassLoader; +import io.papermc.paper.plugin.provider.classloader.PluginClassLoaderGroup; +import java.lang.reflect.Proxy; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.DriverManager; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.jar.JarFile; +import java.util.logging.Logger; +import org.bukkit.Server; +import org.bukkit.command.PluginCommand; +import org.bukkit.event.Listener; +import org.bukkit.plugin.PluginDescriptionFile; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Exercises the packaged entrypoint through Purpur's native JavaPlugin lifecycle. */ +class CreeperFearLifecycleNativeTest { + @TempDir Path temporaryDirectory; + + @Test + void packagedPluginLoadsEnablesCreatesItsSchemaAndDisables() throws Exception { + Path dataDirectory = temporaryDirectory.resolve("CreeperFear"); + List> listenerTypes = new ArrayList<>(); + AtomicReference command = new AtomicReference<>(); + PluginManager manager = port(PluginManager.class, (name, arguments) -> switch (name) { + case "registerEvents" -> { + listenerTypes.add(((Listener) arguments[0]).getClass()); + yield null; + } + default -> throw new AssertionError("Unexpected plugin-manager call " + name); + }); + Server server = port(Server.class, (name, arguments) -> switch (name) { + case "getPluginManager" -> manager; + case "getPluginCommand" -> "creeperaura".equals(arguments[0]) ? command.get() : null; + case "getOnlinePlayers" -> List.of(); + case "getLogger" -> Logger.getLogger("CreeperFear lifecycle fixture"); + case "getWarningState" -> org.bukkit.Warning.WarningState.DEFAULT; + case "getName" -> "Purpur"; + case "getVersion" -> "Purpur 2651 (MC: 26.3)"; + case "getMinecraftVersion" -> "26.3"; + case "isPrimaryThread" -> true; + default -> throw new AssertionError("Unexpected server call " + name); + }); + + try (var loader = new Loader(server, dataDirectory)) { + JavaPlugin plugin = (JavaPlugin) loader.loadClass( + "games.dmg.creeperfear.CreeperFearPlugin").getConstructor().newInstance(); + var constructor = PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class); + constructor.setAccessible(true); + command.set(constructor.newInstance("creeperaura", plugin)); + + assertEquals("CreeperFear", plugin.getName()); + assertEquals("26.3", loader.getConfiguration().getAPIVersion()); + assertFalse(plugin.isEnabled()); + plugin.setEnabled(true); + try { + assertTrue(plugin.isEnabled()); + assertEquals(Set.of( + "CreeperAuraListener", + "CreeperDeathListener", + "CreeperExplosionProgressListener", + "PlayerSessionListener"), listenerTypes.stream() + .map(Class::getSimpleName).collect(java.util.stream.Collectors.toSet())); + assertEquals("CreeperAuraCommand", command.get().getExecutor().getClass().getSimpleName()); + assertEquals("CreeperAuraTabCompleter", command.get().getTabCompleter().getClass().getSimpleName()); + Path database = dataDirectory.resolve("player-progress.sqlite3"); + assertTrue(Files.isRegularFile(database)); + try (var connection = DriverManager.getConnection("jdbc:sqlite:" + database.toAbsolutePath()); + var result = connection.createStatement().executeQuery( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='player_progress'")) { + assertTrue(result.next()); + String schema = result.getString(1); + for (String column : List.of( + "player_uuid", "last_known_name", "rank", "tier_kills", "updated_at")) { + assertTrue(schema.contains(column), schema); + } + } + } finally { + plugin.setEnabled(false); + } + assertFalse(plugin.isEnabled()); + assertTrue(noLiveProgressWorker()); + } + } + + private static boolean noLiveProgressWorker() { + return Thread.getAllStackTraces().keySet().stream() + .noneMatch(thread -> thread.isAlive() && thread.getName().equals("creeper-fear-progress")); + } + + static final class Loader extends URLClassLoader implements ConfiguredPluginClassLoader { + private static final String PACKAGE = "games.dmg.creeperfear."; + private final Server server; + private final Path dataDirectory; + private final Path jar = Path.of(System.getProperty("distribution.jar")); + private final PluginDescriptionFile description; + private JavaPlugin plugin; + + Loader(Server server, Path dataDirectory) throws Exception { + super(new URL[] {Path.of(System.getProperty("distribution.jar")).toUri().toURL()}, + CreeperFearLifecycleNativeTest.class.getClassLoader()); + this.server = server; + this.dataDirectory = dataDirectory; + try (var archive = new JarFile(jar.toFile()); + var input = archive.getInputStream(archive.getJarEntry("plugin.yml"))) { + description = new PluginDescriptionFile(input); + } + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + if (!name.startsWith(PACKAGE) || name.equals(getClass().getName())) { + return super.loadClass(name, resolve); + } + Class loaded = findLoadedClass(name); + if (loaded == null) { + loaded = findClass(name); + } + if (resolve) { + resolveClass(loaded); + } + return loaded; + } + } + + @Override + public Class loadClass(String name, boolean resolve, boolean global, boolean libraries) + throws ClassNotFoundException { + return loadClass(name, resolve); + } + + @Override public PluginMeta getConfiguration() { return description; } + @Override public JavaPlugin getPlugin() { return plugin; } + @Override public PluginClassLoaderGroup getGroup() { return null; } + + @Override + public void init(JavaPlugin created) { + plugin = created; + created.init(server, description, dataDirectory.toFile(), jar.toFile(), this, description, + Logger.getLogger("CreeperFear lifecycle fixture")); + } + } + + @FunctionalInterface + interface Call { + Object invoke(String name, Object[] arguments); + } + + static T port(Class type, Call call) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[] {type}, + (object, method, arguments) -> { + Object[] values = arguments == null ? new Object[0] : arguments; + if (method.getDeclaringClass() == Object.class) { + return switch (method.getName()) { + case "equals" -> object == values[0]; + case "hashCode" -> System.identityHashCode(object); + case "toString" -> "Lifecycle port " + type.getSimpleName(); + default -> throw new AssertionError(method.getName()); + }; + } + return call.invoke(method.getName(), values); + })); + } +} diff --git a/src/test/java/games/dmg/creeperfear/PurpurDistributionTest.java b/src/test/java/games/dmg/creeperfear/PurpurDistributionTest.java index 3234896..b54a777 100644 --- a/src/test/java/games/dmg/creeperfear/PurpurDistributionTest.java +++ b/src/test/java/games/dmg/creeperfear/PurpurDistributionTest.java @@ -6,6 +6,7 @@ import java.io.DataInputStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; +import java.util.Set; import java.util.jar.JarFile; import org.junit.jupiter.api.Test; import org.yaml.snakeyaml.Yaml; @@ -21,30 +22,52 @@ class PurpurDistributionTest { assertEquals(version, metadata.get("version")); assertEquals("CreeperFear", metadata.get("name"), "Retain the data directory and persistent namespace"); assertEquals("games.dmg.creeperfear.CreeperFearPlugin", metadata.get("main")); - assertTrue(((Map) metadata.get("permissions")).containsKey("creeperfear.admin.modify")); + assertEquals("26.3", metadata.get("api-version")); + assertEquals(Set.of("creeperaura"), ((Map) metadata.get("commands")).keySet()); + assertEquals(Set.of( + "creeperfear.progress", + "creeperfear.admin.inspect", + "creeperfear.admin.modify", + "creeperfear.admin.configure"), ((Map) metadata.get("permissions")).keySet()); + assertNull(metadata.get("depend")); + assertNull(metadata.get("softdepend")); assertNotNull(jar.getJarEntry("org/sqlite/JDBC.class"), "The SQLite driver must remain bundled"); assertNotNull(jar.getJarEntry("META-INF/services/java.sql.Driver")); assertEquals("true", jar.getManifest().getMainAttributes().getValue("Multi-Release")); assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/") - || entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class"))); + || entry.getName().startsWith("org/purpurmc/") + || entry.getName().startsWith("io/papermc/") + || entry.getName().startsWith("net/minecraft/") + || entry.getName().startsWith("com/destroystokyo/") + || entry.getName().startsWith("com/comphenix/protocol/") + || entry.getName().endsWith("Test.class"))); } } @Test void pluginBytecodeTargetsJava25() throws Exception { - try (var jar = new JarFile(System.getProperty("distribution.jar")); - var bytecode = new DataInputStream(jar.getInputStream( - jar.getJarEntry("games/dmg/creeperfear/CreeperFearPlugin.class")))) { - assertEquals(0xCAFEBABE, bytecode.readInt()); - bytecode.readUnsignedShort(); - assertEquals(69, bytecode.readUnsignedShort()); + try (var jar = new JarFile(System.getProperty("distribution.jar"))) { + var pluginClasses = jar.stream() + .filter(entry -> entry.getName().startsWith("games/dmg/creeperfear/") + && entry.getName().endsWith(".class")) + .toList(); + assertFalse(pluginClasses.isEmpty()); + for (var entry : pluginClasses) { + try (var bytecode = new DataInputStream(jar.getInputStream(entry))) { + assertEquals(0xCAFEBABE, bytecode.readInt(), entry.getName()); + bytecode.readUnsignedShort(); + assertEquals(69, bytecode.readUnsignedShort(), entry.getName()); + } + } } } @Test void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception { String build = Files.readString(Path.of("build.gradle.kts")); - assertTrue(build.contains("purpur-api:26.2.build.2618-stable")); + assertTrue(build.contains("purpur-api:26.3.build.2651-stable")); + assertTrue(build.contains("https://api.purpurmc.org/v2/purpur/26.3/2651/download")); + assertTrue(build.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598")); assertFalse(build.contains("org.spigotmc:spigot-api")); for (String workflow : new String[] {"ci.yml", "release.yml"}) { String content = Files.readString(Path.of(".gitea/workflows", workflow));