fix(platform): support Purpur 26.3
CI / build (push) Successful in 1m12s
CI / build (pull_request) Successful in 1m20s

This commit is contained in:
dmg committed 2026-10-16 23:46:19 -04:00
1 parent 5b41190a09
commit e33d19b94e
8 files changed
+51 -28

No files matched your search

+5 -5
View File
@@ -1,12 +1,12 @@
# Purpur Tree Feller # Purpur Tree Feller
Tree Feller is a Purpur 26.2 plugin that lets Survival players earn animated automatic felling independently for each vanilla tree family. Tree Feller is a Purpur 26.3 plugin that lets Survival players earn animated automatic felling independently for each vanilla tree family.
The approved behavior and implementation record are in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-tree-feller/index.md). The approved behavior and implementation record are in the [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-tree-feller/index.md).
## Requirements ## Requirements
- Purpur 26.2 build 2618 - Purpur 26.3 build 2651
- Java 25 - Java 25
Repository, checkout and new artifacts use `purpur-tree-feller`. Runtime `TreeFeller`, Java packages/entrypoint, `plugins/TreeFeller/`, commands/permissions and player progression/preferences remain unchanged. Replace the old JAR rather than installing both distributions. Old release tags/assets remain available; new releases no longer support Java 17. Repository, checkout and new artifacts use `purpur-tree-feller`. Runtime `TreeFeller`, Java packages/entrypoint, `plugins/TreeFeller/`, commands/permissions and player progression/preferences remain unchanged. Replace the old JAR rather than installing both distributions. Old release tags/assets remain available; new releases no longer support Java 17.
@@ -17,11 +17,11 @@ Repository, checkout and new artifacts use `purpur-tree-feller`. Runtime `TreeFe
./gradlew clean check jar ./gradlew clean check jar
``` ```
The plugin JAR is written to `build/libs/purpur-tree-feller-<version>.jar`. `check` includes `nativeTreeTest`, loading real item registries/services from SHA-256-verified Purpur 2618 under `build/tree-runtime/` without starting a listening server. Existing item/felling/undo assertions are retained. `LocalTestHarnessTest` checks parsed Compose settings and runs the actual startup script with isolated command doubles; it does not start Docker. The plugin JAR is written to `build/libs/purpur-tree-feller-<version>.jar`. `check` includes `nativeTreeTest`, loading real item registries/services from SHA-256-verified Purpur 26.3 build 2651 under `build/tree-runtime-26.3-2651/` without starting a listening server. The versioned runtime directory prevents verification from reusing extracted 26.2 state. Existing item/felling/undo assertions are retained. `LocalTestHarnessTest` checks parsed Compose settings and runs the actual startup script with isolated command doubles; it does not start Docker.
## Local Docker test server ## Local Docker test server
Build the current plugin and start it on an isolated Java 25/Purpur 26.2 build 2618 server: Build the current plugin and start it on an isolated Java 25/Purpur 26.3 build 2651 server:
```bash ```bash
./scripts/start-test-server.sh ./scripts/start-test-server.sh
@@ -95,7 +95,7 @@ Player preferences, administrative locks, progress, unlocks, and known names are
Tree detection searches matching natural trunk blocks laterally and upward, never below the initiating chop, and requires corresponding foliage or caps. Search bounds prevent unbounded traversal. Leaves, caps, roots, vines, and decorations are not automatically broken. Tree detection searches matching natural trunk blocks laterally and upward, never below the initiating chop, and requires corresponding foliage or caps. Search bounds prevent unbounded traversal. Leaves, caps, roots, vines, and decorations are not automatically broken.
Automatic felling supports Purpur 26.2 wooden, stone, copper, iron, golden, diamond, and netherite axes. Additional trunk blocks use the server's `Player.breakBlock` path so block-break cancellation, drops, experience, enchantments, and axe durability remain authoritative. Protection plugins should cancel `BlockBreakEvent` normally. A cancelled additional break stops the remaining felling. Automatic felling supports Purpur 26.3 wooden, stone, copper, iron, golden, diamond, and netherite axes. Additional trunk blocks use the server's `Player.breakBlock` path so block-break cancellation, drops, experience, enchantments, and axe durability remain authoritative. Protection plugins should cancel `BlockBreakEvent` normally. A cancelled additional break stops the remaining felling.
## Releases ## Releases
+8 -8
View File
@@ -26,9 +26,9 @@ tasks.withType<JavaCompile>().configureEach {
} }
dependencies { 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(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0") testImplementation("org.mockito:mockito-core:5.18.0")
@@ -44,20 +44,20 @@ tasks.test {
} }
// Real item/felling/undo regressions require the declared runtime's registries and service providers. // Real item/felling/undo regressions require the declared runtime's registries and service providers.
val treeRuntime = layout.buildDirectory.dir("tree-runtime") val treeRuntime = layout.buildDirectory.dir("tree-runtime-26.3-2651")
val downloadTreeRuntime = tasks.register("downloadTreeRuntime") { val downloadTreeRuntime = tasks.register("downloadTreeRuntime") {
val launcher = treeRuntime.map { it.file("purpur-26.2-2618.jar") } val launcher = treeRuntime.map { it.file("purpur-26.3-2651.jar") }
outputs.file(launcher) outputs.file(launcher)
doLast { doLast {
val file = launcher.get().asFile val file = launcher.get().asFile
file.parentFile.mkdirs() file.parentFile.mkdirs()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/download").toURL().openConnection() val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/download").toURL().openConnection()
connection.connectTimeout = 30_000 connection.connectTimeout = 30_000
connection.readTimeout = 120_000 connection.readTimeout = 120_000
val bytes = connection.getInputStream().use { it.readBytes() } val bytes = connection.getInputStream().use { it.readBytes() }
val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } val digest = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
check(digest == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") { check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
"Purpur 2618 checksum mismatch" "Purpur 2651 checksum mismatch"
} }
file.writeBytes(bytes) file.writeBytes(bytes)
} }
@@ -65,7 +65,7 @@ val downloadTreeRuntime = tasks.register("downloadTreeRuntime") {
val prepareTreeRuntime = tasks.register<JavaExec>("prepareTreeRuntime") { val prepareTreeRuntime = tasks.register<JavaExec>("prepareTreeRuntime") {
dependsOn(downloadTreeRuntime) dependsOn(downloadTreeRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) } javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(treeRuntime.map { it.file("purpur-26.2-2618.jar") }) classpath = files(treeRuntime.map { it.file("purpur-26.3-2651.jar") })
mainClass = "io.papermc.paperclip.Main" mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true") jvmArgs("-Dpaperclip.patchonly=true")
workingDir(treeRuntime) workingDir(treeRuntime)
+2 -2
View File
@@ -9,8 +9,8 @@ services:
environment: environment:
EULA: "TRUE" EULA: "TRUE"
TYPE: PURPUR TYPE: PURPUR
VERSION: "26.2" VERSION: "26.3"
PURPUR_BUILD: "2618" PURPUR_BUILD: "2651"
MEMORY: 2G MEMORY: 2G
ONLINE_MODE: "false" ONLINE_MODE: "false"
OPS_FILE: /config/tree-feller-ops.json OPS_FILE: /config/tree-feller-ops.json
+2 -2
View File
@@ -32,7 +32,7 @@ if [[ -z "$container_id" ]]; then
exit 1 exit 1
fi fi
echo "Waiting up to ${wait_seconds}s for Purpur 26.2 build 2618..." echo "Waiting up to ${wait_seconds}s for Purpur 26.3 build 2651..."
deadline=$((SECONDS + wait_seconds)) deadline=$((SECONDS + wait_seconds))
while (( SECONDS < deadline )); do while (( SECONDS < deadline )); do
container_state="$(docker inspect --format '{{.State.Status}}' "$container_id")" container_state="$(docker inspect --format '{{.State.Status}}' "$container_id")"
@@ -51,7 +51,7 @@ done
health_state="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")" health_state="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")"
if [[ "$health_state" != "healthy" ]]; then if [[ "$health_state" != "healthy" ]]; then
docker compose -f "$compose_file" logs --tail=150 minecraft >&2 docker compose -f "$compose_file" logs --tail=150 minecraft >&2
echo "Timed out waiting for Purpur 26.2 build 2618 (status: $health_state)." >&2 echo "Timed out waiting for Purpur 26.3 build 2651 (status: $health_state)." >&2
exit 1 exit 1
fi fi
@@ -41,12 +41,12 @@ final class NativeRuntime {
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) { try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess(); LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC)); List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags); List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups, RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join(); RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen); layers = layers.replaceFrom(RegistryLayer.WORLD, world);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources, RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(), Stream.concat(lookups.stream(), world.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join(); RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions); layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName()); Class.forName(org.bukkit.Registry.class.getName());
@@ -21,8 +21,8 @@ class LocalTestHarnessTest {
Map<?, ?> service = (Map<?, ?>) ((Map<?, ?>) compose.get("services")).get("minecraft"); Map<?, ?> service = (Map<?, ?>) ((Map<?, ?>) compose.get("services")).get("minecraft");
Map<?, ?> environment = (Map<?, ?>) service.get("environment"); Map<?, ?> environment = (Map<?, ?>) service.get("environment");
assertEquals("PURPUR", environment.get("TYPE")); assertEquals("PURPUR", environment.get("TYPE"));
assertEquals("2618", environment.get("PURPUR_BUILD")); assertEquals("2651", environment.get("PURPUR_BUILD"));
assertEquals("26.2", environment.get("VERSION")); assertEquals("26.3", environment.get("VERSION"));
assertEquals("itzg/minecraft-server:java25", service.get("image")); assertEquals("itzg/minecraft-server:java25", service.get("image"));
assertEquals("tree-feller-test", compose.get("name")); assertEquals("tree-feller-test", compose.get("name"));
assertEquals("tree-feller-test", service.get("container_name")); assertEquals("tree-feller-test", service.get("container_name"));
@@ -48,7 +48,7 @@ class LocalTestHarnessTest {
void startupVerifiesBuildBeforeRecreateAndRetainsWorldState() throws Exception { void startupVerifiesBuildBeforeRecreateAndRetainsWorldState() throws Exception {
Result result = runStartup(false); Result result = runStartup(false);
assertEquals(0, result.exitCode(), result.output()); assertEquals(0, result.exitCode(), result.output());
assertTrue(result.output().contains("Purpur 26.2 build 2618")); assertTrue(result.output().contains("Purpur 26.3 build 2651"));
assertTrue(result.output().contains("Tree Feller test server is ready")); assertTrue(result.output().contains("Tree Feller test server is ready"));
assertTrue(result.calls().indexOf("gradle clean check jar") < result.calls().indexOf("up -d --force-recreate")); assertTrue(result.calls().indexOf("gradle clean check jar") < result.calls().indexOf("up -d --force-recreate"));
assertTrue(result.calls().contains("rcon-cli plugins")); assertTrue(result.calls().contains("rcon-cli plugins"));
@@ -17,6 +17,7 @@ class PluginMetadataTest {
assertEquals("TreeFeller", metadata.get("name")); assertEquals("TreeFeller", metadata.get("name"));
assertEquals("games.dmg.treefeller.TreeFellerPlugin", metadata.get("main")); assertEquals("games.dmg.treefeller.TreeFellerPlugin", metadata.get("main"));
assertEquals("1.20", metadata.get("api-version"));
Map<?, ?> commands = (Map<?, ?>) metadata.get("commands"); Map<?, ?> commands = (Map<?, ?>) metadata.get("commands");
assertNotNull(commands.get("treefeller")); assertNotNull(commands.get("treefeller"));
assertNotNull(commands.get("treefelleradmin")); assertNotNull(commands.get("treefelleradmin"));
@@ -21,9 +21,10 @@ class PurpurDistributionTest {
assertEquals(version, metadata.get("version")); assertEquals(version, metadata.get("version"));
assertEquals("TreeFeller", metadata.get("name"), "Retain the data directory and persistent namespace"); assertEquals("TreeFeller", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals("games.dmg.treefeller.TreeFellerPlugin", metadata.get("main")); assertEquals("games.dmg.treefeller.TreeFellerPlugin", metadata.get("main"));
assertEquals("1.20", metadata.get("api-version"), "Retain the deliberate compatibility floor");
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("treefeller.admin")); assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("treefeller.admin"));
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/") assertFalse(jar.stream().anyMatch(entry -> isForbiddenDistributionEntry(entry.getName())));
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class"))); assertNotNull(jar.getJarEntry("games/dmg/treefeller/TreeFellerPlugin.class"));
} }
} }
@@ -41,7 +42,14 @@ class PurpurDistributionTest {
@Test @Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception { void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts")); 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"));
assertTrue(build.contains("tree-runtime-26.3-2651"));
assertFalse(build.contains("26.2.build.2618-stable") || build.contains("purpur-26.2-2618.jar")
|| build.contains("/purpur/26.2/2618/download")
|| build.contains("4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31"));
assertFalse(build.contains("org.spigotmc:spigot-api")); assertFalse(build.contains("org.spigotmc:spigot-api"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) { for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow)); String content = Files.readString(Path.of(".gitea/workflows", workflow));
@@ -50,4 +58,18 @@ class PurpurDistributionTest {
assertFalse(content.contains("build/libs/tree-feller-") || content.contains("name: tree-feller-"), workflow); assertFalse(content.contains("build/libs/tree-feller-") || content.contains("name: tree-feller-"), workflow);
} }
} }
private static boolean isForbiddenDistributionEntry(String name) {
return name.startsWith("org/bukkit/")
|| name.startsWith("org/purpurmc/")
|| name.startsWith("io/papermc/paper/")
|| 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$")
|| name.endsWith("NativeRuntime.class");
}
} }