Compare commits

..
2 Commits
Author SHA1 Message Date
dmg 99707958e8 fix(tyrant): support Purpur 26.3 (#1)
Release / release (push) Successful in 1m41s
CI / build (push) Successful in 1m18s
OpenProject: https://project.dmg.games/work_packages/418
2026-10-17 03:53:16 +00:00
dmg 755b9e8d03 fix(tyrant): support Purpur 26.3
CI / build (pull_request) Successful in 1m1s
CI / build (push) Successful in 1m24s
2026-10-16 23:50:55 -04:00
4 changed files with 233 additions and 5 deletions

No files matched your search

+3 -1
View File
@@ -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-<version>.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.
+62 -2
View File
@@ -1,3 +1,6 @@
import java.net.URI
import java.security.MessageDigest
plugins {
java
}
@@ -24,9 +27,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")
@@ -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<JavaExec>("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<Test>("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 {
@@ -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<String> 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<String> output) {
return output.isDone() ? output.join() : "<server still running>";
}
}
@@ -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"));