feat(platform): support Purpur 26.3
CI / build (pull_request) Successful in 49s
CI / build (push) Successful in 1m7s

This commit is contained in:
dmg committed 2026-10-16 23:49:39 -04:00
1 parent 4abddb5d4d
commit 7496564b94
4 files changed
+231 -10

No files matched your search

+4 -4
View File
@@ -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
+95 -3
View File
@@ -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<JavaCompile>().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<Test>("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 {
@@ -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));
@@ -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);
}
}