feat(tree-feller): migrate distribution and local harness to Purpur
Release / release (push) Successful in 7m13s
CI / build (push) Successful in 2m35s

BREAKING CHANGE: New releases require Java 25/Purpur 26.2 and use purpur-tree-feller-<version>.jar. Runtime TreeFeller identity, data folder, progression and felling/undo behavior are unchanged. Replace the old JAR rather than installing both distributions. The local harness now selects Purpur 2618; its stored worlds and operator/port/RCON settings are preserved. No containers are started by this migration.
This commit is contained in:
dmg
2026-09-12 15:04:03 -04:00
parent 2a793e8c69
commit 5b41190a09
15 changed files with 334 additions and 30 deletions
@@ -25,6 +25,9 @@ import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
class AnimatedTreeFellingEngineTest {
@org.junit.jupiter.api.BeforeAll
static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void breaksRemainingTrunkBlocksBottomToTopAtTheConfiguredInterval() {
TestTree tree = new TestTree();
@@ -0,0 +1,61 @@
package games.dmg.treefeller;
import java.util.List;
import java.util.stream.Stream;
import net.minecraft.SharedConstants;
import net.minecraft.commands.Commands;
import net.minecraft.core.HolderLookup;
import net.minecraft.core.LayeredRegistryAccess;
import net.minecraft.core.Registry;
import net.minecraft.core.RegistryAccess;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.RegistryDataLoader;
import net.minecraft.server.Bootstrap;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.RegistryLayer;
import net.minecraft.server.ReloadableServerResources;
import net.minecraft.server.packs.PackType;
import net.minecraft.server.packs.repository.ServerPacksSource;
import net.minecraft.server.packs.resources.MultiPackResourceManager;
import net.minecraft.server.permissions.LevelBasedPermissionSet;
import net.minecraft.tags.TagLoader;
import net.minecraft.util.Util;
import net.minecraft.world.flag.FeatureFlags;
import net.minecraft.world.level.DataPackConfig;
import net.minecraft.world.level.WorldDataConfiguration;
import org.bukkit.craftbukkit.CraftRegistry;
/** Loads vanilla registries/tags/components using the same path as the server's own tests. */
final class NativeRuntime {
private NativeRuntime() {}
private static boolean ready;
static synchronized void bootstrap() throws Exception {
if (ready) { return; }
SharedConstants.tryDetectVersion();
Bootstrap.bootStrap();
var flags = FeatureFlags.VANILLA_SET;
var packs = ServerPacksSource.createVanillaTrustedRepository();
MinecraftServer.configurePackRepository(packs, new WorldDataConfiguration(new DataPackConfig(
FeatureFlags.REGISTRY.toNames(flags).stream().map(Identifier::getPath).toList(), List.of()), flags), true, false);
try (var resources = new MultiPackResourceManager(PackType.SERVER_DATA, packs.openAllSelected())) {
LayeredRegistryAccess<RegistryLayer> layers = RegistryLayer.createRegistryAccess();
List<Registry.PendingTags<?>> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC));
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLDGEN), tags);
RegistryAccess.Frozen worldgen = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLDGEN_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLDGEN, worldgen);
RegistryAccess.Frozen dimensions = RegistryDataLoader.load(resources,
Stream.concat(lookups.stream(), worldgen.listRegistries()).toList(),
RegistryDataLoader.DIMENSION_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
var datapack = ReloadableServerResources.loadResources(resources, layers, tags, flags,
Commands.CommandSelection.DEDICATED, LevelBasedPermissionSet.ALL_PERMISSIONS,
Util.backgroundExecutor(), Runnable::run).join();
datapack.updateComponentsAndStaticRegistryTags();
CraftRegistry.setMinecraftRegistry(layers.compositeAccess().freeze());
}
ready = true;
}
}
@@ -22,6 +22,9 @@ import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
class TreeFellingListenerTest {
@org.junit.jupiter.api.BeforeAll
static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void startsOnlyForAnEnabledUnlockedNonSneakingSurvivalAxeUser() {
UUID playerId = UUID.randomUUID();
@@ -18,6 +18,9 @@ import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
class TreeProgressListenerTest {
@org.junit.jupiter.api.BeforeAll
static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void recordsNaturalTrunkMaterialAfterTheTreeStructureIsNoLongerIntact() throws Exception {
UUID playerId = UUID.randomUUID();
@@ -26,6 +26,9 @@ import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
class TreeUndoServiceTest {
@org.junit.jupiter.api.BeforeAll
static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
private static final Instant FELLED_AT = Instant.parse("2026-08-11T20:00:00Z");
@Test
@@ -0,0 +1,114 @@
package games.dmg.treefeller;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.yaml.snakeyaml.Yaml;
/** Executes the real startup script with isolated CLI doubles; never starts Docker. */
class LocalTestHarnessTest {
@TempDir Path temporary;
@Test
void composeUsesPinnedPurpurAndCurrentJarWithoutChangingIsolationOrState() throws Exception {
Map<?, ?> compose = new Yaml().load(Files.readString(Path.of("compose.test.yml")));
Map<?, ?> service = (Map<?, ?>) ((Map<?, ?>) compose.get("services")).get("minecraft");
Map<?, ?> environment = (Map<?, ?>) service.get("environment");
assertEquals("PURPUR", environment.get("TYPE"));
assertEquals("2618", environment.get("PURPUR_BUILD"));
assertEquals("26.2", environment.get("VERSION"));
assertEquals("itzg/minecraft-server:java25", service.get("image"));
assertEquals("tree-feller-test", compose.get("name"));
assertEquals("tree-feller-test", service.get("container_name"));
assertEquals(List.of("127.0.0.1:25565:25565"), service.get("ports"));
assertEquals(List.of("./.docker/minecraft:/data",
"./build/libs/purpur-tree-feller-0.1.0-SNAPSHOT.jar:/plugins/TreeFeller.jar:ro",
"./docker/test-ops.json:/config/tree-feller-ops.json:ro"), service.get("volumes"));
assertEquals("false", environment.get("ONLINE_MODE"));
assertEquals("true", environment.get("OVERRIDE_OPS"));
assertEquals("/config/tree-feller-ops.json", environment.get("OPS_FILE"));
assertEquals("true", environment.get("ENABLE_RCON"));
assertEquals("${TREE_FELLER_RCON_PASSWORD:-tree-feller-local-test}", environment.get("RCON_PASSWORD"));
List<?> ops = new Yaml().load(Files.readString(Path.of("docker/test-ops.json")));
assertEquals(1, ops.size());
Map<?, ?> operator = (Map<?, ?>) ops.getFirst();
assertEquals("WindMagi", operator.get("name"));
assertEquals("6a3b6e9f-1a2f-380c-8a75-7a7ca6392c0e", operator.get("uuid"));
assertEquals(4, operator.get("level"));
assertTrue(Files.readString(Path.of(".gitignore")).contains(".docker/"));
}
@Test
void startupVerifiesBuildBeforeRecreateAndRetainsWorldState() throws Exception {
Result result = runStartup(false);
assertEquals(0, result.exitCode(), result.output());
assertTrue(result.output().contains("Purpur 26.2 build 2618"));
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().contains("rcon-cli plugins"));
assertTrue(result.calls().contains("cat /data/ops.json"));
assertFalse(result.calls().contains("down"));
assertEquals("existing world", Files.readString(temporary.resolve(".docker/minecraft/sentinel")));
}
@Test
void failedBuildNeverStartsAContainer() throws Exception {
Result result = runStartup(true);
assertNotEquals(0, result.exitCode());
assertTrue(result.calls().contains("gradle clean check jar"));
assertFalse(result.calls().contains("up -d"));
assertFalse(result.calls().contains("rcon-cli"));
}
private Result runStartup(boolean failBuild) throws Exception {
Files.createDirectories(temporary.resolve("scripts"));
Files.createDirectories(temporary.resolve("bin"));
Files.createDirectories(temporary.resolve(".docker/minecraft"));
Files.writeString(temporary.resolve(".docker/minecraft/sentinel"), "existing world");
Files.copy(Path.of("scripts/start-test-server.sh"), temporary.resolve("scripts/start-test-server.sh"));
Files.copy(Path.of("docker/test-ops.json"), temporary.resolve("ops.json"));
executable(temporary.resolve("gradlew"), "#!/bin/sh\nprintf 'gradle %s\\n' \"$*\" >> \"$TEST_CALLS\"\nexit " + (failBuild ? "1" : "0") + "\n");
executable(temporary.resolve("bin/docker"), """
#!/bin/sh
printf 'docker %s\n' "$*" >> "$TEST_CALLS"
case "$*" in
info|'compose version') exit 0 ;;
*'up -d --force-recreate') exit 0 ;;
*'ps -q minecraft') echo fake-container ;;
*'{{if .State.Health}}'*) echo healthy ;;
*'{{.State.Status}}'*) echo running ;;
*'rcon-cli plugins') echo TreeFeller ;;
*'cat /data/ops.json') cat "$TEST_OPS" ;;
*) echo 'Unexpected Docker command' >&2; exit 42 ;;
esac
""");
Path output = temporary.resolve("output");
Path calls = temporary.resolve("calls");
var builder = new ProcessBuilder("bash", temporary.resolve("scripts/start-test-server.sh").toString());
builder.environment().put("PATH", temporary.resolve("bin") + ":" + System.getenv("PATH"));
builder.environment().put("TEST_CALLS", calls.toString());
builder.environment().put("TEST_OPS", temporary.resolve("ops.json").toString());
builder.environment().put("TREE_FELLER_START_TIMEOUT", "5");
builder.redirectErrorStream(true).redirectOutput(output.toFile());
Process process = builder.start();
try {
assertTrue(process.waitFor(15, TimeUnit.SECONDS), "Isolated startup script timed out");
return new Result(process.exitValue(), Files.readString(output), Files.readString(calls));
} finally {
if (process.isAlive()) { process.destroyForcibly(); }
}
}
private static void executable(Path path, String content) throws Exception {
Files.writeString(path, content);
assertTrue(path.toFile().setExecutable(true));
}
private record Result(int exitCode, String output, String calls) { }
}
@@ -0,0 +1,53 @@
package games.dmg.treefeller;
import static org.junit.jupiter.api.Assertions.*;
import java.io.DataInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.jar.JarFile;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
class PurpurDistributionTest {
@Test
void packagesNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
Path artifact = Path.of(System.getProperty("distribution.jar"));
String version = System.getProperty("distribution.version");
assertEquals("purpur-tree-feller-" + version + ".jar", artifact.getFileName().toString());
try (var jar = new JarFile(artifact.toFile())) {
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals(version, metadata.get("version"));
assertEquals("TreeFeller", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals("games.dmg.treefeller.TreeFellerPlugin", metadata.get("main"));
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("treefeller.admin"));
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")));
}
}
@Test
void pluginBytecodeTargetsJava25() throws Exception {
try (var jar = new JarFile(System.getProperty("distribution.jar"));
var bytecode = new DataInputStream(jar.getInputStream(
jar.getJarEntry("games/dmg/treefeller/TreeFellerPlugin.class")))) {
assertEquals(0xCAFEBABE, bytecode.readInt());
bytecode.readUnsignedShort();
assertEquals(69, bytecode.readUnsignedShort());
}
}
@Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
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));
assertTrue(content.contains("java-version: 25"));
assertTrue(content.contains("purpur-tree-feller-"), workflow);
assertFalse(content.contains("build/libs/tree-feller-") || content.contains("name: tree-feller-"), workflow);
}
}
}