diff --git a/README.md b/README.md index 1afa7a7..0120fd9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Purpur Quest Board -A Purpur 26.2 plugin for shared physical quest boards. Players can escrow rewards for item deliveries or player bounties, fulfill them, and collect exact rewards through shared boards. +A Purpur 26.3 plugin for shared physical quest boards. Players can escrow rewards for item deliveries or player bounties, fulfill them, and collect exact rewards through shared boards. The approved behavior is specified in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-quest-board/index.md). @@ -36,8 +36,8 @@ The repository, checkout and new JARs use `purpur-quest-board`. Runtime `SpigotQ ## Requirements -- Purpur 26.2 build 2618 -- Java 25 or newer +- Purpur 26.3 build 2651 +- Java 25 ## Build @@ -45,7 +45,7 @@ The repository, checkout and new JARs use `purpur-quest-board`. Runtime `SpigotQ ./gradlew clean check jar ``` -The plugin JAR is written to `build/libs/`. +The plugin JAR is written to `build/libs/`. The verification task downloads the fixed Purpur 26.3 build 2651 runtime, verifies SHA-256 `0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598`, and starts an isolated server to prove plugin load, enable, and orderly disable. Mock/API tests remain separate coverage for dialogs, boards, escrow, claims, persistence, and ownership locking. ## Player commands diff --git a/build.gradle.kts b/build.gradle.kts index bbe0a1e..9f0be00 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,8 @@ +import java.net.URI +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.security.MessageDigest + plugins { java } @@ -23,9 +28,9 @@ 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") - 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") @@ -33,13 +38,100 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +val purpurVersion = "26.3" +val purpurBuild = "2651" +val purpurRuntimeSha256 = "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598" +val purpurRuntimeJar = layout.projectDirectory.file(".gradle/purpur-runtime/purpur-$purpurVersion-$purpurBuild.jar") + +fun sha256(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().buffered().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) } +} + +val downloadPurpurRuntime = tasks.register("downloadPurpurRuntime") { + description = "Downloads and verifies the exact supported Purpur runtime" + group = "verification" + outputs.file(purpurRuntimeJar) + outputs.upToDateWhen { false } + doLast { + val destination = purpurRuntimeJar.asFile + destination.parentFile.mkdirs() + if (!destination.isFile || sha256(destination) != purpurRuntimeSha256) { + Files.deleteIfExists(destination.toPath()) + val temporary = Files.createTempFile(destination.parentFile.toPath(), destination.name, ".part") + try { + val connection = URI( + "https://api.purpurmc.org/v2/purpur/$purpurVersion/$purpurBuild/download" + ).toURL().openConnection().apply { + connectTimeout = 30_000 + readTimeout = 120_000 + } + connection.getInputStream().use { input -> + Files.newOutputStream(temporary).use(input::copyTo) + } + val actual = sha256(temporary.toFile()) + check(actual == purpurRuntimeSha256) { + "Purpur runtime SHA-256 mismatch: expected $purpurRuntimeSha256, got $actual" + } + Files.move( + temporary, + destination.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING + ) + } finally { + Files.deleteIfExists(temporary) + } + } + check(sha256(destination) == purpurRuntimeSha256) { + "Cached Purpur runtime failed SHA-256 verification" + } + } +} + +val java25Launcher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(25) +} + +val pluginJar = tasks.jar.flatMap { it.archiveFile } + tasks.test { useJUnitPlatform() dependsOn(tasks.jar) - systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath) + exclude("**/PurpurRuntimeLifecycleTest.class") + javaLauncher = java25Launcher + systemProperty("distribution.jar", pluginJar.get().asFile.absolutePath) systemProperty("distribution.version", project.version.toString()) } +val purpurRuntimeTest = tasks.register("purpurRuntimeTest") { + description = "Loads, enables, and disables the plugin on the supported Purpur runtime" + group = "verification" + dependsOn(downloadPurpurRuntime, tasks.jar) + testClassesDirs = sourceSets.test.get().output.classesDirs + classpath = sourceSets.test.get().runtimeClasspath + useJUnitPlatform() + include("**/PurpurRuntimeLifecycleTest.class") + javaLauncher = java25Launcher + systemProperty("purpur.runtime.jar", purpurRuntimeJar.asFile.absolutePath) + systemProperty("purpur.runtime.version", purpurVersion) + systemProperty("purpur.runtime.build", purpurBuild) + systemProperty("distribution.jar", pluginJar.get().asFile.absolutePath) + shouldRunAfter(tasks.test) +} + +tasks.check { + dependsOn(purpurRuntimeTest) +} + val pluginVersion = version tasks.processResources { diff --git a/src/test/java/games/dmg/spigotquestboard/PurpurDistributionTest.java b/src/test/java/games/dmg/spigotquestboard/PurpurDistributionTest.java index e9d57ad..ef80d87 100644 --- a/src/test/java/games/dmg/spigotquestboard/PurpurDistributionTest.java +++ b/src/test/java/games/dmg/spigotquestboard/PurpurDistributionTest.java @@ -21,11 +21,27 @@ class PurpurDistributionTest { assertEquals(version, metadata.get("version")); assertEquals("SpigotQuestBoard", metadata.get("name"), "Retain the data directory and persistent namespace"); assertEquals("games.dmg.spigotquestboard.SpigotQuestBoardPlugin", metadata.get("main")); + assertEquals("1.20", metadata.get("api-version"), "Retain the deliberate compatibility floor"); + assertEquals(java.util.Set.of("quests", "questadmin"), ((Map) metadata.get("commands")).keySet()); assertTrue(((Map) metadata.get("permissions")).containsKey("spigotquestboard.admin")); + assertNull(metadata.get("depend")); + assertNull(metadata.get("softdepend")); + assertNull(metadata.get("loadbefore")); var description = new org.bukkit.plugin.PluginDescriptionFile(jar.getInputStream(jar.getJarEntry("plugin.yml"))); assertEquals("spigotquestboard", description.namespace()); - assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/") - || entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class"))); + assertFalse(jar.stream().anyMatch(entry -> { + String name = entry.getName(); + return name.startsWith("org/bukkit/") + || name.startsWith("org/purpurmc/") + || name.startsWith("io/papermc/") + || name.startsWith("com/destroystokyo/paper/") + || name.startsWith("net/minecraft/") + || name.startsWith("com/comphenix/protocol/") + || name.startsWith("org/junit/") + || name.startsWith("org/mockito/") + || name.endsWith("Test.class") + || name.contains("/test/"); + })); } } @@ -43,7 +59,8 @@ class PurpurDistributionTest { @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")); + assertFalse(build.contains("26.2.build.2618")); 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)); diff --git a/src/test/java/games/dmg/spigotquestboard/PurpurRuntimeLifecycleTest.java b/src/test/java/games/dmg/spigotquestboard/PurpurRuntimeLifecycleTest.java new file mode 100644 index 0000000..7add6c8 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/PurpurRuntimeLifecycleTest.java @@ -0,0 +1,112 @@ +package games.dmg.spigotquestboard; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PurpurRuntimeLifecycleTest { + private static final Duration STARTUP_TIMEOUT = Duration.ofMinutes(3); + + @TempDir + Path serverDirectory; + + @Test + void loadsEnablesAndDisablesOnExactSupportedPurpurRuntime() throws Exception { + Path runtimeJar = requiredPath("purpur.runtime.jar"); + Path pluginJar = requiredPath("distribution.jar"); + String version = System.getProperty("purpur.runtime.version"); + String build = System.getProperty("purpur.runtime.build"); + + Path plugins = Files.createDirectories(serverDirectory.resolve("plugins")); + Files.copy(pluginJar, plugins.resolve(pluginJar.getFileName()), StandardCopyOption.REPLACE_EXISTING); + Files.writeString(serverDirectory.resolve("eula.txt"), "eula=true\n", UTF_8); + Files.writeString(serverDirectory.resolve("server.properties"), String.join("\n", + "level-name=runtime-world", + "online-mode=false", + "server-port=0", + "spawn-protection=0", + "sync-chunk-writes=true", + "") , UTF_8); + + Process process = new ProcessBuilder(List.of( + javaExecutable().toString(), + "-Xms256M", + "-Xmx768M", + "-jar", + runtimeJar.toString(), + "--nogui" + )).directory(serverDirectory.toFile()).redirectErrorStream(true).start(); + + StringBuffer output = new StringBuffer(); + AtomicBoolean stopSent = new AtomicBoolean(); + try (var readerExecutor = Executors.newSingleThreadExecutor( + Thread.ofPlatform().name("purpur-runtime-output").factory())) { + var outputRead = readerExecutor.submit(() -> readOutputAndStop(process, output, stopSent)); + boolean exited = process.waitFor(STARTUP_TIMEOUT.toSeconds(), TimeUnit.SECONDS); + if (!exited) { + process.destroyForcibly(); + process.waitFor(30, TimeUnit.SECONDS); + } + outputRead.get(30, TimeUnit.SECONDS); + + String log = output.toString(); + assertTrue(exited, () -> "Purpur did not stop within " + STARTUP_TIMEOUT + "\n" + log); + assertEquals(0, process.exitValue(), log); + assertTrue(stopSent.get(), () -> "Purpur never completed startup\n" + log); + assertTrue(log.contains("Purpur") && log.contains(version) && log.contains(build), + () -> "Expected Purpur " + version + " build " + build + " identity\n" + log); + assertTrue(log.contains("Enabling SpigotQuestBoard"), log); + assertTrue(log.contains("Disabling SpigotQuestBoard"), log); + assertFalse(log.contains("Could not load plugin"), log); + assertFalse(log.contains("Error occurred while enabling SpigotQuestBoard"), log); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(30, TimeUnit.SECONDS); + } + } + } + + private static Void readOutputAndStop(Process process, StringBuffer output, AtomicBoolean stopSent) + throws IOException { + try (var reader = new BufferedReader(new InputStreamReader(process.getInputStream(), UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append('\n'); + if (line.contains("Done (") && stopSent.compareAndSet(false, true)) { + process.getOutputStream().write("stop\n".getBytes(UTF_8)); + process.getOutputStream().flush(); + } + } + } + return null; + } + + private static Path requiredPath(String property) { + String value = System.getProperty(property); + assertFalse(value == null || value.isBlank(), property + " must be configured"); + Path path = Path.of(value); + assertTrue(Files.isRegularFile(path), () -> property + " does not exist: " + path); + return path; + } + + private static Path javaExecutable() { + String executable = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"; + return Path.of(System.getProperty("java.home"), "bin", executable); + } +}