feat(tree-feller): support Poplar progression (#2)
Release / release (push) Successful in 1m26s
CI / build (push) Successful in 1m9s

Implements SM-5.
This commit was merged in pull request #2.
This commit is contained in:
dmg committed 2026-10-29 12:30:00 +00:00
commit 41090491a2
14 files changed
+374 -5

No files matched your search

@@ -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();
@@ -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));
@@ -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");