5 Commits
Author SHA1 Message Date
dmg 41090491a2 feat(tree-feller): support Poplar progression (#2)
Release / release (push) Successful in 1m26s
CI / build (push) Successful in 1m9s
Implements SM-5.
2026-10-29 12:30:00 +00:00
dmg a9c5aeccdc feat(tree-feller): support Poplar progression
CI / build (pull_request) Successful in 1m56s
CI / build (push) Successful in 1m10s
2026-10-29 08:20:03 -04:00
dmg 5ddc49f79f docs: point story tracking to Kaneo
Release / release (push) Successful in 1m31s
CI / build (push) Successful in 1m5s
2026-10-28 20:36:26 -04:00
dmg df2925b9f6 fix(platform): support Purpur 26.3 (#1)
Release / release (push) Successful in 1m18s
CI / build (push) Successful in 1m6s
OpenProject #416; verified candidate e33d19b94e.
2026-10-17 03:48:57 +00:00
dmg e33d19b94e fix(platform): support Purpur 26.3
CI / build (push) Successful in 1m12s
CI / build (pull_request) Successful in 1m20s
2026-10-16 23:46:19 -04:00
23 changed files with 430 additions and 36 deletions

No files matched your search

+5 -3
View File
@@ -1,9 +1,11 @@
# 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).
Canonical engineering guidance and technical/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/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.
Stories, acceptance criteria, status and evidence belong in [Kaneo Social Minecraft](https://cloud.kaneo.app/dmg/SM), with subjects `[purpur-tree-feller] Descriptive title` (no `US-###` or `FS-*` prefix). Use the configured authenticated `kaneo` MCP server per [Kaneo access and migration](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md#kaneo-access-and-migration); report unavailable access rather than creating local story copies. OpenProject is read-only history.
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 Kaneo tickets with subject prefix `[purpur-tree-feller]`. 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/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.
Follow the full [development process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md) and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md) for approvals, test-first/full-suite verification, required Gitea PR checks/reviews and merge, and merged-tree verification before ticket completion. Never push source `main` directly. 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.
+5 -5
View File
@@ -1,12 +1,12 @@
# 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).
## Requirements
- Purpur 26.2 build 2618
- Purpur 26.3 build 2651
- 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.
@@ -17,11 +17,11 @@ Repository, checkout and new artifacts use `purpur-tree-feller`. Runtime `TreeFe
./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
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
./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.
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
+8 -8
View File
@@ -26,9 +26,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")
@@ -44,20 +44,20 @@ tasks.test {
}
// 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 launcher = treeRuntime.map { it.file("purpur-26.2-2618.jar") }
val launcher = treeRuntime.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.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.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"
check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
"Purpur 2651 checksum mismatch"
}
file.writeBytes(bytes)
}
@@ -65,7 +65,7 @@ val downloadTreeRuntime = tasks.register("downloadTreeRuntime") {
val prepareTreeRuntime = tasks.register<JavaExec>("prepareTreeRuntime") {
dependsOn(downloadTreeRuntime)
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"
jvmArgs("-Dpaperclip.patchonly=true")
workingDir(treeRuntime)
+2 -2
View File
@@ -9,8 +9,8 @@ services:
environment:
EULA: "TRUE"
TYPE: PURPUR
VERSION: "26.2"
PURPUR_BUILD: "2618"
VERSION: "26.3"
PURPUR_BUILD: "2651"
MEMORY: 2G
ONLINE_MODE: "false"
OPS_FILE: /config/tree-feller-ops.json
+2 -2
View File
@@ -32,7 +32,7 @@ if [[ -z "$container_id" ]]; then
exit 1
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))
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 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
fi
@@ -15,6 +15,7 @@ public enum TreeSpecies {
MANGROVE("mangrove", "Mangrove"),
CHERRY("cherry", "Cherry"),
PALE_OAK("pale-oak", "Pale Oak"),
POPLAR("poplar", "Poplar"),
CRIMSON("crimson", "Crimson Fungus"),
WARPED("warped", "Warped Fungus"),
RED_MUSHROOM("red-mushroom", "Giant Red Mushroom"),
@@ -19,6 +19,7 @@ public final class TreeTaxonomy {
Map.entry(Material.MANGROVE_LOG, TreeSpecies.MANGROVE),
Map.entry(Material.CHERRY_LOG, TreeSpecies.CHERRY),
Map.entry(Material.PALE_OAK_LOG, TreeSpecies.PALE_OAK),
Map.entry(Material.POPLAR_LOG, TreeSpecies.POPLAR),
Map.entry(Material.CRIMSON_STEM, TreeSpecies.CRIMSON),
Map.entry(Material.WARPED_STEM, TreeSpecies.WARPED));
@@ -64,6 +65,10 @@ public final class TreeTaxonomy {
foliage.put(TreeSpecies.MANGROVE, Set.of(Material.MANGROVE_LEAVES));
foliage.put(TreeSpecies.CHERRY, Set.of(Material.CHERRY_LEAVES));
foliage.put(TreeSpecies.PALE_OAK, Set.of(Material.PALE_OAK_LEAVES));
foliage.put(TreeSpecies.POPLAR, Set.of(
Material.ORANGE_POPLAR_LEAVES,
Material.RED_POPLAR_LEAVES,
Material.YELLOW_POPLAR_LEAVES));
foliage.put(TreeSpecies.CRIMSON, Set.of(Material.NETHER_WART_BLOCK));
foliage.put(TreeSpecies.WARPED, Set.of(Material.WARPED_WART_BLOCK));
foliage.put(TreeSpecies.RED_MUSHROOM, Set.of(Material.RED_MUSHROOM_BLOCK));
+1
View File
@@ -8,6 +8,7 @@ thresholds:
mangrove: 100
cherry: 100
pale-oak: 100
poplar: 100
crimson: 100
warped: 100
red-mushroom: 100
@@ -65,6 +65,33 @@ class AnimatedTreeFellingEngineTest {
assertEquals(3, completed.size());
}
@Test
void progressivelyFellsPoplarUsingNativeBlockAndToolMaterials() {
TestTree tree = new TestTree();
BlockPoint origin = new BlockPoint(0, 0, 0);
tree.add(origin, Material.POPLAR_LOG);
tree.add(new BlockPoint(0, 1, 0), Material.POPLAR_LOG);
QueueScheduler scheduler = new QueueScheduler();
when(tree.player().breakBlock(any(Block.class))).thenReturn(true);
List<FelledBlockSnapshot> completed = new ArrayList<>();
AnimatedTreeFellingEngine engine = new AnimatedTreeFellingEngine(
scheduler,
new AutomaticBreakRegistry(),
ignored -> true,
(ignored, snapshots) -> completed.addAll(snapshots),
ignored -> { });
assertTrue(engine.start(
tree.player(),
tree.block(origin),
new TreeStructure(TreeSpecies.POPLAR, List.of(origin, new BlockPoint(0, 1, 0))),
2));
scheduler.runAll();
assertEquals(List.of(Material.POPLAR_LOG, Material.POPLAR_LOG),
completed.stream().map(FelledBlockSnapshot::material).toList());
}
@Test
void stopsAfterAProtectionCancellationAndDoesNotRunAnOverlappingFelling() {
TestTree tree = new TestTree();
@@ -41,12 +41,12 @@ final class NativeRuntime {
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);
List<HolderLookup.RegistryLookup<?>> lookups = TagLoader.buildUpdatedLookups(layers.getAccessForLoading(RegistryLayer.WORLD), tags);
RegistryAccess.Frozen world = RegistryDataLoader.load(resources, lookups,
RegistryDataLoader.WORLD_REGISTRIES, Util.backgroundExecutor()).join();
layers = layers.replaceFrom(RegistryLayer.WORLD, world);
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();
layers = layers.replaceFrom(RegistryLayer.DIMENSIONS, dimensions);
Class.forName(org.bukkit.Registry.class.getName());
@@ -57,6 +57,36 @@ class TreeFellingListenerTest {
verify(starter, org.mockito.Mockito.times(1)).start(any(), any(), any(), eq(2));
}
@Test
void startsUnlockedPoplarFellingThroughTheNativeBoundary() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
Block block = mock(Block.class);
World world = mock(World.class);
when(block.getWorld()).thenReturn(world);
when(block.getType()).thenReturn(Material.POPLAR_LOG);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Player");
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.IRON_AXE));
TreeStructure tree = new TreeStructure(
TreeSpecies.POPLAR, List.of(new BlockPoint(0, 0, 0), new BlockPoint(0, 1, 0)));
PlayerStateStore states = mock(PlayerStateStore.class);
when(states.load(playerId)).thenReturn(Optional.of(
PlayerTreeFellerState.initial(playerId, "Player")
.withUnlocked(TreeSpecies.POPLAR, true)));
TreeFellingStarter starter = mock(TreeFellingStarter.class);
TreeFellingListener listener = new TreeFellingListener(
ignored -> Optional.of(tree), states, new AutomaticBreakRegistry(), starter, () -> 2);
listener.onBlockBreak(new BlockBreakEvent(block, player));
verify(starter).start(player, block, tree, 2);
}
@Test
void aNonAxeCanEarnProgressButNeverStartsAutomaticFelling() {
UUID playerId = UUID.randomUUID();
@@ -21,6 +21,41 @@ class TreeProgressListenerTest {
@org.junit.jupiter.api.BeforeAll
static void bootstrap() throws Exception { NativeRuntime.bootstrap(); }
@Test
void recordsAndPersistsPoplarProgressThroughUnlock() throws Exception {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.class);
Block block = mock(Block.class);
World world = mock(World.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Player");
when(player.getGameMode()).thenReturn(GameMode.SURVIVAL);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.IRON_AXE));
when(block.getWorld()).thenReturn(world);
when(block.getType()).thenReturn(Material.POPLAR_LOG);
when(world.getUID()).thenReturn(UUID.randomUUID());
when(block.getX()).thenReturn(1);
when(block.getY()).thenReturn(64);
when(block.getZ()).thenReturn(2);
InMemoryStateStore states = new InMemoryStateStore();
states.state = PlayerTreeFellerState.initial(playerId, "Player")
.withProgress(TreeSpecies.POPLAR, 1);
TreeProgressListener listener = new TreeProgressListener(
ignored -> Optional.empty(),
states,
ignored -> 2,
new AutomaticBreakRegistry(),
(ignoredPlayer, ignoredUpdate) -> { },
ignored -> { });
listener.onBlockBreak(new BlockBreakEvent(block, player));
assertEquals(2, states.state.progress(TreeSpecies.POPLAR));
assertEquals(true, states.state.isUnlocked(TreeSpecies.POPLAR));
}
@Test
void recordsNaturalTrunkMaterialAfterTheTreeStructureIsNoLongerIntact() throws Exception {
UUID playerId = UUID.randomUUID();
@@ -67,6 +67,24 @@ class TreeUndoServiceTest {
assertTrue(fixture.store.get(fixture.playerId).isEmpty());
}
@Test
void consumesAndRestoresOnlyFelledPoplarTrunks() {
Fixture fixture = new Fixture(FELLED_AT.plusSeconds(60));
fixture.record(List.of(
fixture.snapshot(0, Material.POPLAR_LOG),
fixture.snapshot(1, Material.POPLAR_LOG)));
when(fixture.inventory.getStorageContents()).thenReturn(new ItemStack[] {
new ItemStack(Material.POPLAR_LOG, 2)
});
UndoResult result = fixture.service().undo(fixture.player, 6);
assertEquals(UndoStatus.SUCCESS, result.status());
verify(fixture.inventory).setItem(0, null);
verify(fixture.block, org.mockito.Mockito.times(2))
.setBlockData(fixture.restoredData, false);
}
@Test
void refusesTheWholeUndoWhenAnyRestorationPositionIsOccupied() {
Fixture fixture = new Fixture(FELLED_AT.plusSeconds(60));
@@ -21,8 +21,8 @@ class LocalTestHarnessTest {
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("2651", environment.get("PURPUR_BUILD"));
assertEquals("26.3", 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"));
@@ -48,7 +48,7 @@ class LocalTestHarnessTest {
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("Purpur 26.3 build 2651"));
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"));
@@ -17,6 +17,7 @@ class PluginMetadataTest {
assertEquals("TreeFeller", metadata.get("name"));
assertEquals("games.dmg.treefeller.TreeFellerPlugin", metadata.get("main"));
assertEquals("1.20", metadata.get("api-version"));
Map<?, ?> commands = (Map<?, ?>) metadata.get("commands");
assertNotNull(commands.get("treefeller"));
assertNotNull(commands.get("treefelleradmin"));
@@ -21,9 +21,10 @@ class PurpurDistributionTest {
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"));
assertEquals("1.20", metadata.get("api-version"), "Retain the deliberate compatibility floor");
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")));
assertFalse(jar.stream().anyMatch(entry -> isForbiddenDistributionEntry(entry.getName())));
assertNotNull(jar.getJarEntry("games/dmg/treefeller/TreeFellerPlugin.class"));
}
}
@@ -41,7 +42,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"));
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"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
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);
}
}
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");
}
}
@@ -0,0 +1,133 @@
package games.dmg.treefeller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.io.InputStreamReader;
import java.lang.reflect.Constructor;
import java.nio.charset.StandardCharsets;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.PluginCommand;
import org.bukkit.command.SimpleCommandMap;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.Test;
class RegisteredCommandAutocompleteTest {
@Test
void registeredPlayerCommandFiltersEveryApplicableArgumentPositionAndDeniesPermission()
throws Exception {
InMemoryCatalog states = new InMemoryCatalog();
SimpleCommandMap commands = registered(
"treefeller",
"treefeller.command",
new TreeFellerCommand(states, ignored -> { }));
Player denied = mock(Player.class);
Player player = mock(Player.class);
when(player.hasPermission("treefeller.command")).thenReturn(true);
assertEquals(List.of(), complete(commands, denied, "treefeller unl"));
assertEquals(List.of("unlocked"), complete(commands, player, "treefeller unl"));
assertEquals(List.of("off", "on"), complete(commands, player, "treefeller enabled "));
assertEquals(List.of(), complete(commands, player, "treefeller unlocked extra"));
assertEquals(List.of(), complete(commands, player, "treefeller unsupported "));
assertEquals(0, states.saveCount);
}
@Test
void registeredAdminCommandFiltersPoplarAtEverySpeciesPositionAndNeverMutates()
throws Exception {
Player target = mock(Player.class);
when(target.getUniqueId()).thenReturn(UUID.randomUUID());
when(target.getName()).thenReturn("Target");
Server server = mock(Server.class);
Collection<Player> players = List.of(target);
when(server.getOnlinePlayers()).thenAnswer(ignored -> players);
when(server.getPlayer(target.getUniqueId())).thenReturn(target);
InMemoryCatalog states = new InMemoryCatalog();
TreeFellerAdminCommand handler = new TreeFellerAdminCommand(
server,
states,
new TreeFellerSettingsService(defaults(), (species, threshold) -> { }),
ignored -> { });
SimpleCommandMap commands = registered(
"treefelleradmin", "treefeller.admin", handler);
CommandSender denied = mock(CommandSender.class);
CommandSender administrator = mock(CommandSender.class);
when(administrator.hasPermission("treefeller.admin")).thenReturn(true);
assertEquals(List.of(), complete(commands, denied, "treefelleradmin "));
assertEquals(List.of("poplar"), complete(
commands, administrator, "treefelleradmin threshold pop"));
assertEquals(List.of("poplar"), complete(
commands, administrator, "treefelleradmin player Target tree pop"));
assertEquals(List.of("grant"), complete(
commands, administrator, "treefelleradmin player Target tree poplar g"));
assertEquals(List.of(), complete(
commands, administrator, "treefelleradmin player Target tree poplar grant extra"));
assertEquals(List.of(), complete(
commands, administrator, "treefelleradmin unsupported "));
assertEquals(0, states.saveCount);
}
private List<String> complete(
SimpleCommandMap commands, CommandSender sender, String commandLine) {
List<String> completions = commands.tabComplete(sender, commandLine);
if (completions == null) {
return List.of();
}
return completions.stream()
.map(value -> value.substring(value.lastIndexOf(' ') + 1))
.toList();
}
private SimpleCommandMap registered(
String name, String permission, CommandExecutor handler) throws Exception {
Constructor<PluginCommand> constructor = PluginCommand.class
.getDeclaredConstructor(String.class, Plugin.class);
constructor.setAccessible(true);
PluginCommand command = constructor.newInstance(name, mock(Plugin.class));
command.setPermission(permission);
command.setExecutor(handler);
command.setTabCompleter((org.bukkit.command.TabCompleter) handler);
SimpleCommandMap commandMap = new SimpleCommandMap(mock(Server.class), new HashMap<>());
commandMap.register("treefeller", command);
return commandMap;
}
private TreeFellerSettings defaults() throws Exception {
try (InputStreamReader reader = new InputStreamReader(
getClass().getClassLoader().getResourceAsStream("config.yml"),
StandardCharsets.UTF_8)) {
return TreeFellerSettings.load(YamlConfiguration.loadConfiguration(reader));
}
}
private static final class InMemoryCatalog implements PlayerStateCatalog {
private int saveCount;
@Override
public Optional<PlayerTreeFellerState> load(UUID playerId) {
return Optional.empty();
}
@Override
public List<PlayerTreeFellerState> loadAll() {
return List.of();
}
@Override
public void save(PlayerTreeFellerState changed) {
saveCount++;
}
}
}
@@ -84,20 +84,47 @@ class TreeFellerAdminCommandTest {
@Test
void changesAndPersistsAValidatedSpeciesThreshold() throws Exception {
InMemoryCatalog states = new InMemoryCatalog();
List<String> saves = new ArrayList<>();
TreeFellerSettingsService settings = new TreeFellerSettingsService(
defaults(), (species, threshold) -> { });
defaults(), (species, threshold) -> saves.add(species.id() + ":" + threshold));
TreeFellerAdminCommand handler = new TreeFellerAdminCommand(
mock(Server.class), states, settings, ignored -> { });
CommandSender administrator = administrator();
handler.onCommand(administrator, mock(Command.class), "treefelleradmin",
new String[] {"threshold", "dark-oak", "25"});
new String[] {"threshold", "poplar", "25"});
assertEquals(25, settings.current().threshold(TreeSpecies.DARK_OAK));
verify(administrator).sendMessage(contains("Dark Oak"));
assertEquals(List.of("poplar:25"), saves);
assertEquals(25, settings.current().threshold(TreeSpecies.POPLAR));
verify(administrator).sendMessage(contains("Poplar"));
verify(administrator).sendMessage(contains("25"));
}
@Test
void administersPoplarAndRejectsUnsupportedArgumentsWithoutMutation() throws Exception {
UUID playerId = UUID.randomUUID();
Player target = mock(Player.class);
when(target.getUniqueId()).thenReturn(playerId);
when(target.getName()).thenReturn("Target");
InMemoryCatalog states = new InMemoryCatalog();
states.state = PlayerTreeFellerState.initial(playerId, "Target")
.withProgress(TreeSpecies.POPLAR, 9);
TreeFellerAdminCommand handler = handler(serverWith(target), states);
CommandSender administrator = administrator();
handler.onCommand(administrator, mock(Command.class), "treefelleradmin",
new String[] {"player", "Target", "status"});
verify(administrator).sendMessage(contains("Poplar: locked (9/100)"));
handler.onCommand(administrator, mock(Command.class), "treefelleradmin",
new String[] {"player", "Target", "tree", "poplar", "grant"});
assertTrue(states.state.isUnlocked(TreeSpecies.POPLAR));
handler.onCommand(administrator, mock(Command.class), "treefelleradmin",
new String[] {"player", "Target", "tree", "poplar", "grant", "extra"});
assertEquals(1, states.saveCount);
}
@Test
void autocompleteIsPermissionAwareAndPositionSpecific() throws Exception {
Player target = mock(Player.class);
@@ -113,11 +140,26 @@ class TreeFellerAdminCommandTest {
administrator, mock(Command.class), "treefelleradmin", new String[] {"p"}));
assertEquals(List.of("Target"), handler.onTabComplete(
administrator, mock(Command.class), "treefelleradmin", new String[] {"player", "T"}));
assertEquals(List.of("poplar"), handler.onTabComplete(
administrator,
mock(Command.class),
"treefelleradmin",
new String[] {"threshold", "pop"}));
assertEquals(List.of("poplar"), handler.onTabComplete(
administrator,
mock(Command.class),
"treefelleradmin",
new String[] {"player", "Target", "tree", "pop"}));
assertEquals(List.of("grant"), handler.onTabComplete(
administrator,
mock(Command.class),
"treefelleradmin",
new String[] {"player", "Target", "tree", "oak", "g"}));
new String[] {"player", "Target", "tree", "poplar", "g"}));
assertEquals(List.of(), handler.onTabComplete(
administrator,
mock(Command.class),
"treefelleradmin",
new String[] {"player", "Target", "tree", "poplar", "grant", "extra"}));
}
private TreeFellerAdminCommand handler(Server server, InMemoryCatalog states) throws Exception {
@@ -152,6 +194,7 @@ class TreeFellerAdminCommandTest {
private static final class InMemoryCatalog implements PlayerStateCatalog {
private PlayerTreeFellerState state;
private int saveCount;
@Override
public Optional<PlayerTreeFellerState> load(UUID playerId) {
@@ -168,6 +211,7 @@ class TreeFellerAdminCommandTest {
@Override
public void save(PlayerTreeFellerState changed) {
state = changed;
saveCount++;
}
}
}
@@ -73,6 +73,7 @@ class TreeFellerCommandTest {
verify(player).sendMessage(contains("Oak: locked (12/100)"));
verify(player).sendMessage(contains("Birch: unlocked"));
verify(player).sendMessage(contains("Poplar: locked (0/100)"));
verify(player, atLeastOnce()).sendMessage(contains("Mushroom"));
}
@@ -24,6 +24,20 @@ class TreeFellerSettingsTest {
assertEquals(6, settings.undoWindowMinutes());
}
@Test
void acceptsOnlyTheExistingThresholdRangeForPoplar() throws Exception {
TreeFellerSettings settings = TreeFellerSettings.load(loadDefaults());
assertEquals(1, settings.withThreshold(TreeSpecies.POPLAR, 1)
.threshold(TreeSpecies.POPLAR));
assertEquals(1_000_000, settings.withThreshold(TreeSpecies.POPLAR, 1_000_000)
.threshold(TreeSpecies.POPLAR));
assertThrows(IllegalArgumentException.class,
() -> settings.withThreshold(TreeSpecies.POPLAR, 0));
assertThrows(IllegalArgumentException.class,
() -> settings.withThreshold(TreeSpecies.POPLAR, 1_000_001));
}
@Test
void rejectsUnsafeRequiredNumbers() throws Exception {
YamlConfiguration configuration = loadDefaults();
@@ -38,6 +38,27 @@ class TreeStructureScannerTest {
.isEmpty());
}
@Test
void recognizesEveryPoplarFoliageColorAsTheSameSpecies() {
for (Material foliage : new Material[] {
Material.ORANGE_POPLAR_LEAVES,
Material.RED_POPLAR_LEAVES,
Material.YELLOW_POPLAR_LEAVES
}) {
TestBlocks blocks = new TestBlocks();
blocks.put(0, 0, 0, Material.POPLAR_LOG);
blocks.put(0, 1, 0, Material.POPLAR_LOG);
blocks.put(1, 1, 0, foliage);
TreeStructure tree = new TreeStructureScanner(100, 16)
.scan(blocks, new BlockPoint(0, 0, 0))
.orElseThrow();
assertEquals(TreeSpecies.POPLAR, tree.species());
assertEquals(2, tree.trunkBlocks().size());
}
}
@Test
void distinguishesGiantMushroomsByTheirCaps() {
TestBlocks blocks = new TestBlocks();
@@ -20,6 +20,7 @@ class TreeTaxonomyTest {
Map.entry(Material.MANGROVE_LOG, TreeSpecies.MANGROVE),
Map.entry(Material.CHERRY_LOG, TreeSpecies.CHERRY),
Map.entry(Material.PALE_OAK_LOG, TreeSpecies.PALE_OAK),
Map.entry(Material.POPLAR_LOG, TreeSpecies.POPLAR),
Map.entry(Material.CRIMSON_STEM, TreeSpecies.CRIMSON),
Map.entry(Material.WARPED_STEM, TreeSpecies.WARPED));
@@ -29,5 +30,12 @@ class TreeTaxonomyTest {
assertTrue(TreeTaxonomy.directSpecies(Material.OAK_WOOD).isEmpty());
assertTrue(TreeTaxonomy.directSpecies(Material.STRIPPED_OAK_LOG).isEmpty());
assertTrue(TreeTaxonomy.directSpecies(Material.CRIMSON_HYPHAE).isEmpty());
assertTrue(TreeTaxonomy.directSpecies(Material.POPLAR_WOOD).isEmpty());
assertTrue(TreeTaxonomy.directSpecies(Material.STRIPPED_POPLAR_LOG).isEmpty());
assertTrue(TreeTaxonomy.directSpecies(Material.STRIPPED_POPLAR_WOOD).isEmpty());
assertTrue(TreeTaxonomy.directSpecies(Material.POPLAR_PLANKS).isEmpty());
assertTrue(TreeTaxonomy.directSpecies(Material.POPLAR_SAPLING).isEmpty());
assertTrue(TreeTaxonomy.directSpecies(Material.FIREFLY_BUSH).isEmpty());
assertTrue(TreeTaxonomy.directSpecies(Material.RED_SHRUB).isEmpty());
}
}
@@ -43,6 +43,37 @@ class YamlPlayerStateRepositoryTest {
assertFalse(Files.exists(file.resolveSibling("players.yml.tmp")));
}
@Test
void loadsLegacyRecordsWithZeroLockedPoplarProgressAndPreservesExistingData() throws Exception {
Path file = temporaryDirectory.resolve("players.yml");
UUID playerId = UUID.randomUUID();
String root = "players." + playerId;
YamlConfiguration legacy = new YamlConfiguration();
legacy.set(root + ".latest-name", "CurrentName");
legacy.set(root + ".known-names", java.util.List.of("OldName", "CurrentName"));
legacy.set(root + ".enabled", false);
legacy.set(root + ".locked", true);
legacy.set(root + ".progress.oak", 73);
legacy.set(root + ".unlocked", java.util.List.of("birch"));
legacy.set(root + ".future-player", 42);
legacy.save(file.toFile());
YamlPlayerStateRepository repository = new YamlPlayerStateRepository(file);
PlayerTreeFellerState state = repository.load(playerId).orElseThrow();
repository.save(state);
assertEquals(0, state.progress(TreeSpecies.POPLAR));
assertFalse(state.isUnlocked(TreeSpecies.POPLAR));
assertEquals(73, state.progress(TreeSpecies.OAK));
assertTrue(state.isUnlocked(TreeSpecies.BIRCH));
assertFalse(state.enabled());
assertTrue(state.locked());
assertEquals(java.util.Set.of("OldName", "CurrentName"), state.knownNames());
YamlConfiguration saved = YamlConfiguration.loadConfiguration(file.toFile());
assertEquals(42, saved.getInt(root + ".future-player"));
assertEquals(0, saved.getLong(root + ".progress.poplar"));
}
@Test
void rejectsARecordContainingUnknownUnlocksInsteadOfGrantingAccess() throws Exception {
Path file = temporaryDirectory.resolve("players.yml");