diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2d697f6..4770cfe 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: temurin - java-version: 17 + java-version: 25 cache: gradle - name: Validate conventional commits @@ -35,6 +35,6 @@ jobs: - name: Upload development artifact uses: actions/upload-artifact@v3 with: - name: tree-feller-${{ github.sha }} + name: purpur-tree-feller-${{ github.sha }} path: build/libs/*.jar if-no-files-found: error diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4291345..b058a3d 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -21,7 +21,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: temurin - java-version: 17 + java-version: 25 cache: gradle - name: Set up Node.js @@ -86,8 +86,8 @@ jobs: if: steps.release.outputs.created == 'true' uses: actions/upload-artifact@v3 with: - name: tree-feller-${{ steps.release.outputs.version }} - path: build/libs/tree-feller-${{ steps.release.outputs.version }}.jar + name: purpur-tree-feller-${{ steps.release.outputs.version }} + path: build/libs/purpur-tree-feller-${{ steps.release.outputs.version }}.jar if-no-files-found: error - name: Create Gitea release and upload plugin @@ -101,7 +101,7 @@ jobs: PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }} run: | api_url="${GITEA_SERVER_URL}/api/v1" - jar="build/libs/tree-feller-${VERSION}.jar" + jar="build/libs/purpur-tree-feller-${VERSION}.jar" export RELEASE_BODY if [ -n "$PREVIOUS_TAG" ]; then RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD") @@ -138,4 +138,4 @@ jobs: -H "Authorization: token ${GITEA_TOKEN}" \ -H "Content-Type: application/octet-stream" \ --data-binary "@${jar}" \ - "${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=tree-feller-${VERSION}.jar" + "${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=purpur-tree-feller-${VERSION}.jar" diff --git a/AGENTS.md b/AGENTS.md index f8a95db..345129b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,9 @@ -# spigot-tree-feller agent entrypoint +# purpur-tree-feller agent entrypoint The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md). -Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-tree-feller/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-tree-feller/` stories. Also follow the parent workspace `AGENTS.md` when present. +Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-tree-feller/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-tree-feller/` stories. Also follow the parent workspace `AGENTS.md` when present. -For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-tree-feller/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access. +For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-tree-feller/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access. Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push. diff --git a/README.md b/README.md index 2cbb0f8..c095df6 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ -# Tree Feller +# Purpur Tree Feller -Tree Feller is a Spigot 26.2 plugin that lets Survival players earn animated automatic felling independently for each vanilla tree family. +Tree Feller is a Purpur 26.2 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/spigot-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 -- Spigot 26.2 -- Java 17 or newer +- Purpur 26.2 build 2618 +- 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. ## Build @@ -15,11 +17,11 @@ The approved behavior and implementation record are in the [SoMC OKF wiki](https ./gradlew clean check jar ``` -The plugin JAR is written to `build/libs/`. +The plugin JAR is written to `build/libs/purpur-tree-feller-.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. ## Local Docker test server -Build the current plugin and start it on an isolated Spigot 26.2 server: +Build the current plugin and start it on an isolated Java 25/Purpur 26.2 build 2618 server: ```bash ./scripts/start-test-server.sh @@ -27,7 +29,7 @@ Build the current plugin and start it on an isolated Spigot 26.2 server: Connect to `localhost:25565` as `WindMagi`, which the harness configures as a level-4 operator using its deterministic offline-mode UUID. The committed `docker/test-ops.json` fixture replaces local operator state on every startup so the offline UUID remains deterministic. The server uses offline mode for local convenience and binds only to the loopback interface. -The first startup may take several minutes while the image downloads and Spigot is prepared. Override the 15-minute wait or local RCON password when needed: +The first startup may take several minutes while the image downloads and Purpur is prepared. Override the 15-minute wait or local RCON password when needed: ```bash TREE_FELLER_START_TIMEOUT=1200 \ @@ -49,6 +51,8 @@ docker compose -f compose.test.yml down rm -rf .docker/minecraft ``` +The harness now mounts the `purpur-tree-feller` snapshot JAR at the unchanged `/plugins/TreeFeller.jar` path. A manual startup after migration switches the local test world from Spigot to Purpur; it preserves existing `.docker/minecraft/` state, so back up valuable test worlds before running it. The migration itself does not start or recreate containers. + Re-run the start script after code changes to rebuild the JAR and recreate the test container. Generated server state remains under `.docker/minecraft/` and is ignored by Git. ## Player commands @@ -79,7 +83,7 @@ Administrative commands require `treefeller.admin`, granted to server operators - Crimson and warped fungi - Giant red and brown mushrooms -Azalea-grown logs count as oak. Unlock progress counts unstripped natural log and Nether stem materials even after the surrounding tree has been disrupted; wood, hyphae, stripped variants, and bamboo do not count. Player-placed qualifying materials are indistinguishable from generated materials in Spigot and therefore also count. Giant mushroom stems require enough cap context to identify their species. +Azalea-grown logs count as oak. Unlock progress counts unstripped natural log and Nether stem materials even after the surrounding tree has been disrupted; wood, hyphae, stripped variants, and bamboo do not count. Player-placed qualifying materials are indistinguishable from generated materials through the server API and therefore also count. Giant mushroom stems require enough cap context to identify their species. ## Configuration and state @@ -91,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. -Automatic felling supports Spigot 26.2 wooden, stone, copper, iron, golden, diamond, and netherite axes. Additional trunk blocks use Spigot'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.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. ## Releases diff --git a/build.gradle.kts b/build.gradle.kts index 97df943..a94ae6f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import java.net.URI +import java.security.MessageDigest + plugins { java } @@ -9,23 +12,23 @@ version = providers.gradleProperty("releaseVersion") repositories { mavenCentral() - maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") + maven("https://repo.purpurmc.org/snapshots/") } java { toolchain { - languageVersion = JavaLanguageVersion.of(17) + languageVersion = JavaLanguageVersion.of(25) } } tasks.withType().configureEach { - options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror")) + options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror")) } dependencies { - compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") + compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") - testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") + testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.mockito:mockito-core:5.18.0") @@ -35,8 +38,64 @@ dependencies { tasks.test { useJUnitPlatform() + dependsOn(tasks.jar) + systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath) + systemProperty("distribution.version", project.version.toString()) } +// Real item/felling/undo regressions require the declared runtime's registries and service providers. +val treeRuntime = layout.buildDirectory.dir("tree-runtime") +val downloadTreeRuntime = tasks.register("downloadTreeRuntime") { + val launcher = treeRuntime.map { it.file("purpur-26.2-2618.jar") } + outputs.file(launcher) + doLast { + val file = launcher.get().asFile + file.parentFile.mkdirs() + val connection = URI("https://api.purpurmc.org/v2/purpur/26.2/2618/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 == "4a32d046a118804d89ca74ba89b798c98f6d8d1f310c18077ac573597049de31") { + "Purpur 2618 checksum mismatch" + } + file.writeBytes(bytes) + } +} +val prepareTreeRuntime = tasks.register("prepareTreeRuntime") { + dependsOn(downloadTreeRuntime) + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) } + classpath = files(treeRuntime.map { it.file("purpur-26.2-2618.jar") }) + mainClass = "io.papermc.paperclip.Main" + jvmArgs("-Dpaperclip.patchonly=true") + workingDir(treeRuntime) + outputs.dir(treeRuntime.map { it.dir("versions") }) + outputs.dir(treeRuntime.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.implementationConfigurationName, "org.mockito:mockito-core:5.18.0") + add(nativeTest.compileOnlyConfigurationName, "org.jetbrains:annotations:26.0.2") + add(nativeTest.compileOnlyConfigurationName, "org.checkerframework:checker-qual:3.49.2") + add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher") +} +val nativeRuntimeJars = files(fileTree(treeRuntime) { + include("versions/**/*.jar", "libraries/**/*.jar") +}).builtBy(prepareTreeRuntime) +nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars +nativeTest.runtimeClasspath += sourceSets.main.get().output + nativeRuntimeJars +val nativeTreeTest = tasks.register("nativeTreeTest") { + description = "Runs real item/felling/undo regressions without starting a server" + testClassesDirs = nativeTest.output.classesDirs + classpath = nativeTest.runtimeClasspath + useJUnitPlatform() + maxHeapSize = "1G" + workingDir(treeRuntime) +} +tasks.check { dependsOn(nativeTreeTest) } + val pluginVersion = version tasks.processResources { diff --git a/compose.test.yml b/compose.test.yml index eae5319..2bc4d1d 100644 --- a/compose.test.yml +++ b/compose.test.yml @@ -8,8 +8,9 @@ services: - "127.0.0.1:25565:25565" environment: EULA: "TRUE" - TYPE: SPIGOT + TYPE: PURPUR VERSION: "26.2" + PURPUR_BUILD: "2618" MEMORY: 2G ONLINE_MODE: "false" OPS_FILE: /config/tree-feller-ops.json @@ -21,7 +22,7 @@ services: SPAWN_PROTECTION: "0" volumes: - ./.docker/minecraft:/data - - ./build/libs/tree-feller-0.1.0-SNAPSHOT.jar:/plugins/TreeFeller.jar:ro + - ./build/libs/purpur-tree-feller-0.1.0-SNAPSHOT.jar:/plugins/TreeFeller.jar:ro - ./docker/test-ops.json:/config/tree-feller-ops.json:ro healthcheck: test: ["CMD", "mc-health"] diff --git a/scripts/start-test-server.sh b/scripts/start-test-server.sh index 729d731..7fc8065 100755 --- a/scripts/start-test-server.sh +++ b/scripts/start-test-server.sh @@ -32,7 +32,7 @@ if [[ -z "$container_id" ]]; then exit 1 fi -echo "Waiting up to ${wait_seconds}s for Spigot 26.2..." +echo "Waiting up to ${wait_seconds}s for Purpur 26.2 build 2618..." deadline=$((SECONDS + wait_seconds)) while (( SECONDS < deadline )); do 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")" if [[ "$health_state" != "healthy" ]]; then docker compose -f "$compose_file" logs --tail=150 minecraft >&2 - echo "Timed out waiting for Spigot 26.2 (status: $health_state)." >&2 + echo "Timed out waiting for Purpur 26.2 build 2618 (status: $health_state)." >&2 exit 1 fi diff --git a/settings.gradle.kts b/settings.gradle.kts index 7e10846..e0f2514 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1 @@ -rootProject.name = "tree-feller" +rootProject.name = "purpur-tree-feller" diff --git a/src/test/java/games/dmg/treefeller/AnimatedTreeFellingEngineTest.java b/src/nativeTest/java/games/dmg/treefeller/AnimatedTreeFellingEngineTest.java similarity index 98% rename from src/test/java/games/dmg/treefeller/AnimatedTreeFellingEngineTest.java rename to src/nativeTest/java/games/dmg/treefeller/AnimatedTreeFellingEngineTest.java index af97bf3..92a4527 100644 --- a/src/test/java/games/dmg/treefeller/AnimatedTreeFellingEngineTest.java +++ b/src/nativeTest/java/games/dmg/treefeller/AnimatedTreeFellingEngineTest.java @@ -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(); diff --git a/src/nativeTest/java/games/dmg/treefeller/NativeRuntime.java b/src/nativeTest/java/games/dmg/treefeller/NativeRuntime.java new file mode 100644 index 0000000..4fd9750 --- /dev/null +++ b/src/nativeTest/java/games/dmg/treefeller/NativeRuntime.java @@ -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 layers = RegistryLayer.createRegistryAccess(); + List> tags = TagLoader.loadTagsForExistingRegistries(resources, layers.getLayer(RegistryLayer.STATIC)); + List> 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; + } +} diff --git a/src/test/java/games/dmg/treefeller/TreeFellingListenerTest.java b/src/nativeTest/java/games/dmg/treefeller/TreeFellingListenerTest.java similarity index 98% rename from src/test/java/games/dmg/treefeller/TreeFellingListenerTest.java rename to src/nativeTest/java/games/dmg/treefeller/TreeFellingListenerTest.java index 5703c5d..1af05ee 100644 --- a/src/test/java/games/dmg/treefeller/TreeFellingListenerTest.java +++ b/src/nativeTest/java/games/dmg/treefeller/TreeFellingListenerTest.java @@ -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(); diff --git a/src/test/java/games/dmg/treefeller/TreeProgressListenerTest.java b/src/nativeTest/java/games/dmg/treefeller/TreeProgressListenerTest.java similarity index 96% rename from src/test/java/games/dmg/treefeller/TreeProgressListenerTest.java rename to src/nativeTest/java/games/dmg/treefeller/TreeProgressListenerTest.java index f66f152..8d5be9e 100644 --- a/src/test/java/games/dmg/treefeller/TreeProgressListenerTest.java +++ b/src/nativeTest/java/games/dmg/treefeller/TreeProgressListenerTest.java @@ -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(); diff --git a/src/test/java/games/dmg/treefeller/TreeUndoServiceTest.java b/src/nativeTest/java/games/dmg/treefeller/TreeUndoServiceTest.java similarity index 98% rename from src/test/java/games/dmg/treefeller/TreeUndoServiceTest.java rename to src/nativeTest/java/games/dmg/treefeller/TreeUndoServiceTest.java index fbffd2f..d3b2376 100644 --- a/src/test/java/games/dmg/treefeller/TreeUndoServiceTest.java +++ b/src/nativeTest/java/games/dmg/treefeller/TreeUndoServiceTest.java @@ -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 diff --git a/src/test/java/games/dmg/treefeller/LocalTestHarnessTest.java b/src/test/java/games/dmg/treefeller/LocalTestHarnessTest.java new file mode 100644 index 0000000..9de439c --- /dev/null +++ b/src/test/java/games/dmg/treefeller/LocalTestHarnessTest.java @@ -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) { } +} diff --git a/src/test/java/games/dmg/treefeller/PurpurDistributionTest.java b/src/test/java/games/dmg/treefeller/PurpurDistributionTest.java new file mode 100644 index 0000000..fcf1f59 --- /dev/null +++ b/src/test/java/games/dmg/treefeller/PurpurDistributionTest.java @@ -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); + } + } +}