From 755b9e8d03b0d1320fc85b5932541deb6be8e75a Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 16 Oct 2026 23:50:55 -0400 Subject: [PATCH] fix(tyrant): support Purpur 26.3 --- README.md | 4 +- build.gradle.kts | 64 +++++++- .../SpigotTyrantLifecycleNativeTest.java | 155 ++++++++++++++++++ .../spigottyrant/PurpurDistributionTest.java | 15 +- 4 files changed, 233 insertions(+), 5 deletions(-) create mode 100644 src/nativeTest/java/games/dmg/spigottyrant/SpigotTyrantLifecycleNativeTest.java diff --git a/README.md b/README.md index d3dc9f8..070f848 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Purpur Tyrant -The asymmetric Tyrant/Vigilante game plugin, targeting Java 25 and Purpur 26.2 build 2618. +The asymmetric Tyrant/Vigilante game plugin, targeting Java 25 and Purpur 26.3 build 2651. ## Build and releases @@ -10,6 +10,8 @@ The asymmetric Tyrant/Vigilante game plugin, targeting Java 25 and Purpur 26.2 b New artifacts use `build/libs/purpur-tyrant-.jar`. Gitea CI checks pushes/pull requests; approved conventional commits on main drive versioned releases. Distribution tests inspect the actual JAR, version, Java 25 bytecode, retained runtime identity and workflow names. +The full verification command compiles against Purpur API `26.3.build.2651-stable`, downloads build 2651 from the Purpur API, verifies SHA-256 `0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598`, and starts a clean loopback-only Purpur server. The native lifecycle check loads, enables, commands, and disables the packaged plugin while proving persisted role/game state and a captured-entity snapshot survive the cycle. It uses generated build-local server state, not the untracked Compose environment, and is automated compatibility evidence rather than a live-player test. + ## Migration compatibility The repository and checkout are now `purpur-tyrant`, previously `spigot-tyrant`. Runtime identity remains **SpigotTyrant**, including `plugins/SpigotTyrant/`, Java entrypoints/packages, command/permission names and persistent keys for roles, captured mobs and legacy items. The migration changes the build/distribution target, not gameplay or persisted schemas. New releases no longer support Java 17. diff --git a/build.gradle.kts b/build.gradle.kts index a1b1857..abf9459 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import java.net.URI +import java.security.MessageDigest + plugins { java } @@ -24,9 +27,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") @@ -41,6 +44,63 @@ 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( + "purpur.launcher", + nativeRuntime.map { it.file("purpur-26.3-2651.jar") }.get().asFile.absolutePath + ) +} +tasks.check { dependsOn(nativeLifecycleTest) } + val pluginVersion = version tasks.processResources { diff --git a/src/nativeTest/java/games/dmg/spigottyrant/SpigotTyrantLifecycleNativeTest.java b/src/nativeTest/java/games/dmg/spigottyrant/SpigotTyrantLifecycleNativeTest.java new file mode 100644 index 0000000..ab13806 --- /dev/null +++ b/src/nativeTest/java/games/dmg/spigottyrant/SpigotTyrantLifecycleNativeTest.java @@ -0,0 +1,155 @@ +package games.dmg.spigottyrant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Test; + +/** Exercises the packaged plugin through a real, isolated Purpur server lifecycle. */ +final class SpigotTyrantLifecycleNativeTest { + private static final String TYRANT_ID = "11111111-1111-1111-1111-111111111111"; + + @Test + void packagedPluginLoadsStateAndCapturedEntitySnapshotAcrossEnableAndDisable() + throws Exception { + Path runtime = Path.of("").toAbsolutePath(); + Path plugins = Files.createDirectories(runtime.resolve("plugins")); + Path dataDirectory = Files.createDirectories(plugins.resolve("SpigotTyrant")); + Files.copy( + Path.of(System.getProperty("distribution.jar")), + plugins.resolve("purpur-tyrant-native-test.jar"), + StandardCopyOption.REPLACE_EXISTING + ); + try (var config = getClass().getClassLoader().getResourceAsStream("config.yml")) { + if (config == null) { + throw new AssertionError("Missing packaged default config"); + } + Files.copy(config, dataDirectory.resolve("config.yml")); + } + Files.writeString(dataDirectory.resolve("state.yml"), """ + game: + lifecycle: RUNNING + tyrant-id: 11111111-1111-1111-1111-111111111111 + tyrant-level: 3 + unspent-choices: 2 + purchases: [ASSASSIN] + claimed-armor: [HELMET] + players: + 11111111-1111-1111-1111-111111111111: + name: NativeTyrant + tyrant-class: TAMER + captured-mobs: + - entity-type: ZOMBIE + data: + snapshot: '{id:"minecraft:zombie",CustomName:"Native"}' + """); + Files.writeString(runtime.resolve("eula.txt"), "eula=true\n"); + Files.writeString(runtime.resolve("server.properties"), """ + online-mode=false + server-port=0 + query.port=0 + enable-query=false + enable-rcon=false + spawn-protection=0 + view-distance=2 + simulation-distance=2 + level-name=native-lifecycle-world + motd=Spigot Tyrant native lifecycle + """); + + Process process = new ProcessBuilder( + Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-Xms512M", + "-Xmx1G", + "-jar", + System.getProperty("purpur.launcher"), + "--nogui" + ).directory(runtime.toFile()).redirectErrorStream(true).start(); + CountDownLatch started = new CountDownLatch(1); + CompletableFuture output = CompletableFuture.supplyAsync(() -> + captureOutput(process, started) + ); + try { + assertTrue(started.await(120, TimeUnit.SECONDS), () -> + "Purpur did not finish startup:\n" + completedOutput(output) + ); + process.outputWriter(StandardCharsets.UTF_8).append("tyrantadmin status\n") + .append("tyrantadmin grantpoint\n").flush(); + awaitPersistedProgress(dataDirectory.resolve("state.yml")); + process.outputWriter(StandardCharsets.UTF_8).append("stop\n").flush(); + assertTrue(process.waitFor(60, TimeUnit.SECONDS), "Purpur did not stop cleanly"); + } finally { + if (process.isAlive()) { + process.destroyForcibly(); + process.waitFor(10, TimeUnit.SECONDS); + } + } + + String log = output.get(10, TimeUnit.SECONDS); + assertEquals(0, process.exitValue(), log); + assertTrue(log.contains("Enabling SpigotTyrant"), log); + assertTrue(log.contains("Spigot Tyrant enabled."), log); + assertTrue(log.contains("Lifecycle: RUNNING"), log); + assertTrue(log.contains("Tyrant: " + TYRANT_ID), log); + assertTrue(log.contains("NativeTyrant (" + TYRANT_ID + "): class=TAMER"), log); + assertTrue(log.contains("Disabling SpigotTyrant"), log); + + PersistentState saved = new YamlTyrantStateRepository( + dataDirectory.resolve("state.yml") + ).load(); + assertEquals(4, saved.game().tyrantLevel()); + assertEquals(3, saved.game().unspentChoices()); + CapturedMob captured = saved.players().values().iterator().next().capturedMobs().getFirst(); + assertEquals("ZOMBIE", captured.entityType()); + assertEquals( + "{id:\"minecraft:zombie\",CustomName:\"Native\"}", + captured.data().get("snapshot") + ); + } + + private static String captureOutput(Process process, CountDownLatch started) { + StringBuilder output = new StringBuilder(); + try (var reader = new BufferedReader(new InputStreamReader( + process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append('\n'); + if (line.contains("Done (") && line.contains("For help, type")) { + started.countDown(); + } + } + return output.toString(); + } catch (IOException exception) { + throw new CompletionException(exception); + } + } + + private static void awaitPersistedProgress(Path stateFile) throws Exception { + long deadline = System.nanoTime() + Duration.ofSeconds(20).toNanos(); + while (System.nanoTime() < deadline) { + PersistentState state = new YamlTyrantStateRepository(stateFile).load(); + if (state.game().tyrantLevel() == 4 && state.game().unspentChoices() == 3) { + return; + } + Thread.sleep(100); + } + throw new TimeoutException("Tyrant state did not reflect the native grantpoint command"); + } + + private static String completedOutput(CompletableFuture output) { + return output.isDone() ? output.join() : ""; + } +} diff --git a/src/test/java/games/dmg/spigottyrant/PurpurDistributionTest.java b/src/test/java/games/dmg/spigottyrant/PurpurDistributionTest.java index 3e868b3..dac9b08 100644 --- a/src/test/java/games/dmg/spigottyrant/PurpurDistributionTest.java +++ b/src/test/java/games/dmg/spigottyrant/PurpurDistributionTest.java @@ -23,7 +23,12 @@ class PurpurDistributionTest { assertEquals("games.dmg.spigottyrant.SpigotTyrantPlugin", metadata.get("main")); assertTrue(((Map) metadata.get("permissions")).containsKey("spigottyrant.admin")); 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("net/minecraft/") + || entry.getName().startsWith("com/comphenix/protocol/") + || entry.getName().contains("/test/") + || entry.getName().contains("/nativeTest/") + || entry.getName().endsWith("Test.class"))); } } @@ -41,8 +46,14 @@ 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")); + assertTrue(build.contains("purpur-26.3-2651.jar")); + assertTrue(build.contains("https://api.purpurmc.org/v2/purpur/26.3/2651/download")); + assertTrue(build.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598")); + assertFalse(build.contains("26.2.build.2618-stable")); assertFalse(build.contains("org.spigotmc:spigot-api")); + String readme = Files.readString(Path.of("README.md")); + assertTrue(readme.contains("Purpur 26.3 build 2651")); for (String workflow : new String[] {"ci.yml", "release.yml"}) { String content = Files.readString(Path.of(".gitea/workflows", workflow)); assertTrue(content.contains("java-version: 25"));