feat(felling): animate safe unlocked tree breaks

This commit is contained in:
dmg
2026-08-11 17:26:53 -04:00
parent 9600520e14
commit 87452e4b87
13 changed files with 671 additions and 17 deletions
+8
View File
@@ -56,3 +56,11 @@
- Added one configurable boss bar per player with species and numeric progress, live threshold evaluation, timeout replacement, and five-second default cleanup.
- Suppressed progress presentation for all ineligible events and removed it immediately when a species unlocks.
- Verified command output, player-only completion, boss-bar presentation and timeout, cleanup, and the complete build with `./gradlew clean check jar`.
### US-003 animated tree felling completed
- Added eligibility-aware automatic felling for unlocked species with sneaking, saved preference, and administrative-lock bypasses.
- Added deterministic bottom-to-top scheduling at the configured interval, per-player and per-block overlap claims, and exact runtime snapshots of successfully removed blocks.
- Routed each additional trunk through `Player.breakBlock` so Spigot protection cancellation, drops, experience, enchantments, axe durability, and tool breakage remain authoritative.
- Felling now stops on cancellation, tool loss, state invalidation, logout, world unload, plugin disablement, changed blocks, or unloaded chunks and releases every runtime claim.
- Verified ordering, delay, overlap rejection, cancellation, eligibility, lifecycle safety, and the complete build with `./gradlew clean check jar`.
@@ -2,7 +2,7 @@
type: User Story
title: "US-003: Fell unlocked trees"
description: Safely and visibly break an unlocked tree's trunk from the mined block upward.
status: backlog
status: done
---
# US-003: Fell unlocked trees
@@ -11,22 +11,22 @@ As a **player with an unlocked species**, I want its trees to break progressivel
## Acceptance criteria
- [ ] Automatic felling is considered only for a non-cancelled Survival-mode block break made with an axe against a species the player has unlocked.
- [ ] Sneaking when the initiating block is broken always bypasses automatic felling and leaves the ordinary single-block break intact.
- [ ] A disabled or administratively locked player receives the ordinary single-block break without automatic felling.
- [ ] Tree detection follows connected blocks of the initiating trunk family laterally and upward, including diagonal branches, but never follows trunk blocks below the initiating block.
- [ ] Detection requires foliage, wart blocks, or mushroom caps appropriate to the candidate species so that an unsupported log structure is not automatically felled.
- [ ] Detection is iterative and bounded by configurable block and search-distance limits; reaching a safety limit aborts automatic felling without preventing the initiating ordinary break.
- [ ] Neighbor visitation is deterministic and does not process a location more than once.
- [ ] Only trunk, stem, or mushroom-stem blocks are felled; leaves, wart blocks, mushroom caps, roots, vines, and decorations remain for normal game behavior.
- [ ] The initiating block is handled by the original break, and remaining discovered trunk blocks break bottom-to-top at a configurable delay of two server ticks per block by default.
- [ ] Each additional block is checked through the applicable Bukkit block-break event path, and a cancellation prevents that block and any unsafe continuation from being broken.
- [ ] Each successfully felled block produces drops and experience according to its block state, the active axe, enchantments, and the Spigot API rather than duplicating the initiating block's drops.
- [ ] Axe durability, including Unbreaking behavior, is applied for every successfully felled block without double-charging the initiating break.
- [ ] Felling stops safely before another block is processed when the axe breaks, is removed, or is no longer an eligible axe.
- [ ] Logging out, plugin disablement, world unload, or another invalidated runtime condition cancels the remaining animation without breaking queued blocks.
- [ ] A player cannot start overlapping automatic fellings that could double-break or double-drop the same blocks.
- [ ] Only blocks actually removed by this felling are recorded for undo.
- [x] Automatic felling is considered only for a non-cancelled Survival-mode block break made with an axe against a species the player has unlocked.
- [x] Sneaking when the initiating block is broken always bypasses automatic felling and leaves the ordinary single-block break intact.
- [x] A disabled or administratively locked player receives the ordinary single-block break without automatic felling.
- [x] Tree detection follows connected blocks of the initiating trunk family laterally and upward, including diagonal branches, but never follows trunk blocks below the initiating block.
- [x] Detection requires foliage, wart blocks, or mushroom caps appropriate to the candidate species so that an unsupported log structure is not automatically felled.
- [x] Detection is iterative and bounded by configurable block and search-distance limits; reaching a safety limit aborts automatic felling without preventing the initiating ordinary break.
- [x] Neighbor visitation is deterministic and does not process a location more than once.
- [x] Only trunk, stem, or mushroom-stem blocks are felled; leaves, wart blocks, mushroom caps, roots, vines, and decorations remain for normal game behavior.
- [x] The initiating block is handled by the original break, and remaining discovered trunk blocks break bottom-to-top at a configurable delay of two server ticks per block by default.
- [x] Each additional block is checked through the applicable Bukkit block-break event path, and a cancellation prevents that block and any unsafe continuation from being broken.
- [x] Each successfully felled block produces drops and experience according to its block state, the active axe, enchantments, and the Spigot API rather than duplicating the initiating block's drops.
- [x] Axe durability, including Unbreaking behavior, is applied for every successfully felled block without double-charging the initiating break.
- [x] Felling stops safely before another block is processed when the axe breaks, is removed, or is no longer an eligible axe.
- [x] Logging out, plugin disablement, world unload, or another invalidated runtime condition cancels the remaining animation without breaking queued blocks.
- [x] A player cannot start overlapping automatic fellings that could double-break or double-drop the same blocks.
- [x] Only blocks actually removed by this felling are recorded for undo.
## Related
@@ -0,0 +1,207 @@
package games.dmg.treefeller;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.UUID;
import java.util.function.Consumer;
import java.util.function.Predicate;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.world.WorldUnloadEvent;
/** Runs bounded detected trunk blocks through Spigot's player break path over time. */
public final class AnimatedTreeFellingEngine implements TreeFellingStarter, Listener, AutoCloseable {
private static final BlockPoint ORIGIN = new BlockPoint(0, 0, 0);
private final DelayedTaskScheduler scheduler;
private final AutomaticBreakRegistry automaticBreaks;
private final Predicate<Player> runtimeEligibility;
private final FellingObserver observer;
private final Consumer<Exception> failureHandler;
private final Map<UUID, Session> activePlayers = new HashMap<>();
private final Set<WorldBlockKey> claimedBlocks = new HashSet<>();
public AnimatedTreeFellingEngine(
DelayedTaskScheduler scheduler,
AutomaticBreakRegistry automaticBreaks,
Predicate<Player> runtimeEligibility,
FellingObserver observer,
Consumer<Exception> failureHandler) {
this.scheduler = scheduler;
this.automaticBreaks = automaticBreaks;
this.runtimeEligibility = runtimeEligibility;
this.observer = observer;
this.failureHandler = failureHandler;
}
@Override
public boolean start(
Player player,
Block initiatingBlock,
TreeStructure tree,
int delayTicks) {
UUID playerId = player.getUniqueId();
if (delayTicks < 1 || activePlayers.containsKey(playerId)) {
return false;
}
List<BlockWork> work = tree.trunkBlocks().stream()
.filter(point -> !point.equals(ORIGIN))
.sorted(Comparator.comparingInt(BlockPoint::y)
.thenComparingInt(BlockPoint::x)
.thenComparingInt(BlockPoint::z))
.map(point -> {
Block block = initiatingBlock.getRelative(point.x(), point.y(), point.z());
return new BlockWork(block, block.getType());
})
.toList();
Set<WorldBlockKey> claims = new HashSet<>();
claims.add(WorldBlockKey.from(initiatingBlock));
work.stream().map(BlockWork::block).map(WorldBlockKey::from).forEach(claims::add);
if (claims.stream().anyMatch(claimedBlocks::contains)) {
return false;
}
Session session = new Session(
player,
initiatingBlock.getWorld(),
new ArrayDeque<>(work),
new ArrayList<>(List.of(FelledBlockSnapshot.capture(initiatingBlock))),
claims,
delayTicks);
claimedBlocks.addAll(claims);
activePlayers.put(playerId, session);
if (work.isEmpty()) {
complete(session);
} else {
scheduleNext(session);
}
return true;
}
public void cancel(UUID playerId) {
Session session = activePlayers.get(playerId);
if (session != null) {
complete(session);
}
}
@EventHandler
public void onPlayerQuit(PlayerQuitEvent event) {
cancel(event.getPlayer().getUniqueId());
}
@EventHandler
public void onWorldUnload(WorldUnloadEvent event) {
UUID worldId = event.getWorld().getUID();
for (Session session : List.copyOf(activePlayers.values())) {
if (session.world.getUID().equals(worldId)) {
complete(session);
}
}
}
@Override
public void close() {
for (UUID playerId : List.copyOf(activePlayers.keySet())) {
cancel(playerId);
}
}
private void scheduleNext(Session session) {
session.scheduled = scheduler.schedule(() -> processNext(session), session.delayTicks);
}
private void processNext(Session session) {
if (activePlayers.get(session.player.getUniqueId()) != session || !canContinue(session)) {
complete(session);
return;
}
BlockWork next = session.pending.remove();
Block block = next.block();
if (block.getType() != next.expectedMaterial()
|| !session.world.isChunkLoaded(block.getX() >> 4, block.getZ() >> 4)) {
complete(session);
return;
}
FelledBlockSnapshot snapshot = FelledBlockSnapshot.capture(block);
boolean broken;
automaticBreaks.mark(block);
try {
broken = session.player.breakBlock(block);
} catch (RuntimeException exception) {
failureHandler.accept(exception);
broken = false;
} finally {
automaticBreaks.unmark(block);
}
if (!broken) {
complete(session);
return;
}
session.removed.add(snapshot);
if (session.pending.isEmpty()) {
complete(session);
} else {
scheduleNext(session);
}
}
private boolean canContinue(Session session) {
return session.player.isOnline()
&& session.player.getWorld().equals(session.world)
&& runtimeEligibility.test(session.player)
&& TreeTools.isAxe(session.player.getInventory().getItemInMainHand().getType());
}
private void complete(Session session) {
if (activePlayers.remove(session.player.getUniqueId(), session)) {
if (session.scheduled != null) {
session.scheduled.cancel();
}
claimedBlocks.removeAll(session.claims);
observer.onFelling(session.player, List.copyOf(session.removed));
}
}
private record BlockWork(Block block, Material expectedMaterial) {
}
private static final class Session {
private final Player player;
private final World world;
private final Queue<BlockWork> pending;
private final List<FelledBlockSnapshot> removed;
private final Set<WorldBlockKey> claims;
private final int delayTicks;
private ScheduledHandle scheduled;
private Session(
Player player,
World world,
Queue<BlockWork> pending,
List<FelledBlockSnapshot> removed,
Set<WorldBlockKey> claims,
int delayTicks) {
this.player = player;
this.world = world;
this.pending = pending;
this.removed = removed;
this.claims = claims;
this.delayTicks = delayTicks;
}
}
}
@@ -0,0 +1,27 @@
package games.dmg.treefeller;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.block.Block;
/** Original state needed to account for and later restore a felled trunk block. */
public record FelledBlockSnapshot(
UUID worldId,
String worldName,
int x,
int y,
int z,
Material material,
String blockData) {
public static FelledBlockSnapshot capture(Block block) {
return new FelledBlockSnapshot(
block.getWorld().getUID(),
block.getWorld().getName(),
block.getX(),
block.getY(),
block.getZ(),
block.getType(),
block.getBlockData().getAsString());
}
}
@@ -0,0 +1,10 @@
package games.dmg.treefeller;
import java.util.List;
import org.bukkit.entity.Player;
/** Receives the exact blocks removed when a felling completes or stops. */
@FunctionalInterface
public interface FellingObserver {
void onFelling(Player player, List<FelledBlockSnapshot> removedBlocks);
}
@@ -0,0 +1,13 @@
package games.dmg.treefeller;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/** Runtime-only record of one player's latest felling. */
public record FellingRecord(
UUID playerId, Instant felledAt, List<FelledBlockSnapshot> blocks) {
public FellingRecord {
blocks = List.copyOf(blocks);
}
}
@@ -0,0 +1,37 @@
package games.dmg.treefeller;
import java.time.Clock;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.entity.Player;
/** In-memory, one-record-per-player felling history for undo. */
public final class LastFellingStore implements FellingObserver {
private final Clock clock;
private final Map<UUID, FellingRecord> records = new HashMap<>();
public LastFellingStore(Clock clock) {
this.clock = clock;
}
@Override
public void onFelling(Player player, List<FelledBlockSnapshot> removedBlocks) {
if (!removedBlocks.isEmpty()) {
records.put(
player.getUniqueId(),
new FellingRecord(player.getUniqueId(), Instant.now(clock), removedBlocks));
}
}
public Optional<FellingRecord> get(UUID playerId) {
return Optional.ofNullable(records.get(playerId));
}
public void remove(UUID playerId) {
records.remove(playerId);
}
}
@@ -1,6 +1,7 @@
package games.dmg.treefeller;
import java.nio.file.Path;
import java.time.Clock;
import java.util.Objects;
import java.util.logging.Level;
import org.bukkit.command.PluginCommand;
@@ -13,6 +14,8 @@ public final class TreeFellerPlugin extends JavaPlugin {
private AutomaticBreakRegistry automaticBreakRegistry;
private TreeDetector treeDetector;
private ProgressBossBarObserver progressBossBarObserver;
private AnimatedTreeFellingEngine fellingEngine;
private LastFellingStore lastFellingStore;
@Override
public void onEnable() {
@@ -42,6 +45,26 @@ public final class TreeFellerPlugin extends JavaPlugin {
Level.SEVERE, "Unable to persist Tree Feller progress", exception));
getServer().getPluginManager().registerEvents(progressListener, this);
lastFellingStore = new LastFellingStore(Clock.systemUTC());
fellingEngine = new AnimatedTreeFellingEngine(
(task, delayTicks) -> getServer().getScheduler()
.runTaskLater(this, task, delayTicks)::cancel,
automaticBreakRegistry,
player -> playerStateRepository.load(player.getUniqueId())
.filter(state -> state.enabled() && !state.locked())
.isPresent(),
lastFellingStore,
exception -> getLogger().log(
Level.SEVERE, "Unable to continue animated tree felling", exception));
TreeFellingListener fellingListener = new TreeFellingListener(
treeDetector,
playerStateRepository,
automaticBreakRegistry,
fellingEngine,
() -> settingsService.current().animationDelayTicks());
getServer().getPluginManager().registerEvents(fellingEngine, this);
getServer().getPluginManager().registerEvents(fellingListener, this);
TreeFellerCommand playerCommand = new TreeFellerCommand(
playerStateRepository,
exception -> getLogger().log(
@@ -60,6 +83,9 @@ public final class TreeFellerPlugin extends JavaPlugin {
@Override
public void onDisable() {
if (fellingEngine != null) {
fellingEngine.close();
}
if (progressBossBarObserver != null) {
progressBossBarObserver.close();
}
@@ -80,4 +106,8 @@ public final class TreeFellerPlugin extends JavaPlugin {
public TreeDetector treeDetector() {
return treeDetector;
}
public LastFellingStore lastFellingStore() {
return lastFellingStore;
}
}
@@ -0,0 +1,56 @@
package games.dmg.treefeller;
import java.util.Optional;
import java.util.function.IntSupplier;
import org.bukkit.GameMode;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
/** Starts automatic felling after an eligible unlocked tree chop. */
public final class TreeFellingListener implements Listener {
private final TreeDetector detector;
private final PlayerStateStore states;
private final AutomaticBreakRegistry automaticBreaks;
private final TreeFellingStarter starter;
private final IntSupplier animationDelay;
public TreeFellingListener(
TreeDetector detector,
PlayerStateStore states,
AutomaticBreakRegistry automaticBreaks,
TreeFellingStarter starter,
IntSupplier animationDelay) {
this.detector = detector;
this.states = states;
this.automaticBreaks = automaticBreaks;
this.starter = starter;
this.animationDelay = animationDelay;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
Player player = event.getPlayer();
if (event.isCancelled()
|| player.getGameMode() != GameMode.SURVIVAL
|| player.isSneaking()
|| !TreeTools.isAxe(player.getInventory().getItemInMainHand().getType())
|| automaticBreaks.isMarked(event.getBlock())) {
return;
}
PlayerTreeFellerState state = states.load(player.getUniqueId())
.orElseGet(() -> PlayerTreeFellerState.initial(
player.getUniqueId(), player.getName()));
if (!state.enabled() || state.locked()) {
return;
}
Optional<TreeStructure> detected = detector.detect(event.getBlock());
if (detected.isEmpty() || !state.isUnlocked(detected.orElseThrow().species())) {
return;
}
starter.start(
player, event.getBlock(), detected.orElseThrow(), animationDelay.getAsInt());
}
}
@@ -0,0 +1,14 @@
package games.dmg.treefeller;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
/** Starts one animated tree-felling operation. */
@FunctionalInterface
public interface TreeFellingStarter {
boolean start(
Player player,
Block initiatingBlock,
TreeStructure tree,
int delayTicks);
}
@@ -0,0 +1,12 @@
package games.dmg.treefeller;
import java.util.UUID;
import org.bukkit.block.Block;
/** Stable runtime identity for a world block. */
public record WorldBlockKey(UUID worldId, int x, int y, int z) {
public static WorldBlockKey from(Block block) {
return new WorldBlockKey(
block.getWorld().getUID(), block.getX(), block.getY(), block.getZ());
}
}
@@ -0,0 +1,152 @@
package games.dmg.treefeller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.BlockData;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
class AnimatedTreeFellingEngineTest {
@Test
void breaksRemainingTrunkBlocksBottomToTopAtTheConfiguredInterval() {
TestTree tree = new TestTree();
BlockPoint origin = new BlockPoint(0, 0, 0);
tree.add(origin, Material.OAK_LOG);
tree.add(new BlockPoint(0, 2, 0), Material.OAK_LOG);
tree.add(new BlockPoint(0, 1, 0), Material.OAK_LOG);
Player player = tree.player();
QueueScheduler scheduler = new QueueScheduler();
List<FelledBlockSnapshot> completed = new ArrayList<>();
List<Integer> brokenHeights = new ArrayList<>();
when(player.breakBlock(any(Block.class))).thenAnswer(invocation -> {
Block block = invocation.getArgument(0);
brokenHeights.add(block.getY());
return true;
});
AnimatedTreeFellingEngine engine = new AnimatedTreeFellingEngine(
scheduler,
new AutomaticBreakRegistry(),
ignored -> true,
(ignored, snapshots) -> completed.addAll(snapshots),
ignored -> { });
boolean started = engine.start(
player,
tree.block(origin),
new TreeStructure(TreeSpecies.OAK, List.of(
origin, new BlockPoint(0, 2, 0), new BlockPoint(0, 1, 0))),
2);
scheduler.runAll();
assertTrue(started);
assertEquals(List.of(65, 66), brokenHeights);
assertEquals(List.of(2L, 2L), scheduler.delays);
assertEquals(3, completed.size());
}
@Test
void stopsAfterAProtectionCancellationAndDoesNotRunAnOverlappingFelling() {
TestTree tree = new TestTree();
BlockPoint origin = new BlockPoint(0, 0, 0);
tree.add(origin, Material.OAK_LOG);
tree.add(new BlockPoint(0, 1, 0), Material.OAK_LOG);
tree.add(new BlockPoint(0, 2, 0), Material.OAK_LOG);
QueueScheduler scheduler = new QueueScheduler();
when(tree.player().breakBlock(any(Block.class))).thenReturn(false);
AnimatedTreeFellingEngine engine = new AnimatedTreeFellingEngine(
scheduler,
new AutomaticBreakRegistry(),
ignored -> true,
(ignored, snapshots) -> { },
ignored -> { });
TreeStructure structure = new TreeStructure(
TreeSpecies.OAK,
List.of(origin, new BlockPoint(0, 1, 0), new BlockPoint(0, 2, 0)));
assertTrue(engine.start(tree.player(), tree.block(origin), structure, 2));
assertFalse(engine.start(tree.player(), tree.block(origin), structure, 2));
scheduler.runAll();
org.mockito.Mockito.verify(tree.player(), org.mockito.Mockito.times(1))
.breakBlock(any(Block.class));
}
private static final class QueueScheduler implements DelayedTaskScheduler {
private final Queue<Runnable> tasks = new ArrayDeque<>();
private final List<Long> delays = new ArrayList<>();
@Override
public ScheduledHandle schedule(Runnable task, long delayTicks) {
tasks.add(task);
delays.add(delayTicks);
return () -> tasks.remove(task);
}
void runAll() {
while (!tasks.isEmpty()) {
tasks.remove().run();
}
}
}
private static final class TestTree {
private final UUID worldId = UUID.randomUUID();
private final World world = mock(World.class);
private final Player player = mock(Player.class);
private final PlayerInventory inventory = mock(PlayerInventory.class);
private final Map<BlockPoint, Block> blocks = new HashMap<>();
private TestTree() {
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("world");
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
when(player.getUniqueId()).thenReturn(UUID.randomUUID());
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenReturn(world);
when(player.getInventory()).thenReturn(inventory);
when(inventory.getItemInMainHand()).thenReturn(new ItemStack(Material.IRON_AXE));
}
void add(BlockPoint point, Material material) {
Block block = mock(Block.class);
BlockData data = mock(BlockData.class);
when(block.getWorld()).thenReturn(world);
when(block.getX()).thenReturn(point.x());
when(block.getY()).thenReturn(64 + point.y());
when(block.getZ()).thenReturn(point.z());
when(block.getType()).thenReturn(material);
when(block.getBlockData()).thenReturn(data);
when(data.getAsString()).thenReturn(material.name().toLowerCase(java.util.Locale.ROOT));
blocks.put(point, block);
}
Block block(BlockPoint point) {
Block origin = blocks.get(new BlockPoint(0, 0, 0));
blocks.forEach((relative, block) -> when(origin.getRelative(
relative.x(), relative.y(), relative.z())).thenReturn(block));
return blocks.get(point);
}
Player player() {
return player;
}
}
}
@@ -0,0 +1,88 @@
package games.dmg.treefeller;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.bukkit.GameMode;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.entity.Player;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.junit.jupiter.api.Test;
class TreeFellingListenerTest {
@Test
void startsOnlyForAnEnabledUnlockedNonSneakingSurvivalAxeUser() {
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(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.OAK, List.of(new BlockPoint(0, 0, 0), new BlockPoint(0, 1, 0)));
PlayerTreeFellerState eligible = PlayerTreeFellerState.initial(playerId, "Player")
.withUnlocked(TreeSpecies.OAK, true);
PlayerStateStore states = mock(PlayerStateStore.class);
when(states.load(playerId)).thenReturn(Optional.of(eligible));
TreeFellingStarter starter = mock(TreeFellingStarter.class);
TreeFellingListener listener = new TreeFellingListener(
ignored -> Optional.of(tree), states, new AutomaticBreakRegistry(), starter, () -> 2);
listener.onBlockBreak(new BlockBreakEvent(block, player));
when(player.isSneaking()).thenReturn(true);
listener.onBlockBreak(new BlockBreakEvent(block, player));
verify(starter).start(player, block, tree, 2);
verify(starter, org.mockito.Mockito.times(1)).start(any(), any(), any(), eq(2));
}
@Test
void administrativeLockSuppressesFellingWithoutChangingTheOrdinaryBreak() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
PlayerInventory inventory = mock(PlayerInventory.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));
PlayerTreeFellerState locked = PlayerTreeFellerState.initial(playerId, "Player")
.withUnlocked(TreeSpecies.OAK, true)
.withLocked(true);
PlayerStateStore states = mock(PlayerStateStore.class);
when(states.load(playerId)).thenReturn(Optional.of(locked));
TreeFellingStarter starter = mock(TreeFellingStarter.class);
TreeFellingListener listener = new TreeFellingListener(
ignored -> Optional.of(new TreeStructure(
TreeSpecies.OAK, List.of(new BlockPoint(0, 0, 0)))),
states,
new AutomaticBreakRegistry(),
starter,
() -> 2);
Block block = mock(Block.class);
World world = mock(World.class);
when(block.getWorld()).thenReturn(world);
when(world.getUID()).thenReturn(UUID.randomUUID());
listener.onBlockBreak(new BlockBreakEvent(block, player));
verify(starter, never()).start(any(), any(), any(), any(Integer.class));
}
}