fix(tyrant): support Purpur 26.3 (#1)
OpenProject: https://project.dmg.games/work_packages/418
This commit was merged in pull request #1.
This commit is contained in:
commit
99707958e8
4 files changed
+233
-5
No files matched your search
@@ -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
@@ -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"));
|
||||
|
||||
Reference in new issue
Block a user