diff --git a/design/log.md b/design/log.md index f36f021..f66781d 100644 --- a/design/log.md +++ b/design/log.md @@ -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`. diff --git a/design/user-stories/us-003-fell-unlocked-trees.md b/design/user-stories/us-003-fell-unlocked-trees.md index 6f11dcb..634d6dd 100644 --- a/design/user-stories/us-003-fell-unlocked-trees.md +++ b/design/user-stories/us-003-fell-unlocked-trees.md @@ -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 diff --git a/src/main/java/games/dmg/treefeller/AnimatedTreeFellingEngine.java b/src/main/java/games/dmg/treefeller/AnimatedTreeFellingEngine.java new file mode 100644 index 0000000..5d91e8a --- /dev/null +++ b/src/main/java/games/dmg/treefeller/AnimatedTreeFellingEngine.java @@ -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 runtimeEligibility; + private final FellingObserver observer; + private final Consumer failureHandler; + private final Map activePlayers = new HashMap<>(); + private final Set claimedBlocks = new HashSet<>(); + + public AnimatedTreeFellingEngine( + DelayedTaskScheduler scheduler, + AutomaticBreakRegistry automaticBreaks, + Predicate runtimeEligibility, + FellingObserver observer, + Consumer 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 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 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 pending; + private final List removed; + private final Set claims; + private final int delayTicks; + private ScheduledHandle scheduled; + + private Session( + Player player, + World world, + Queue pending, + List removed, + Set claims, + int delayTicks) { + this.player = player; + this.world = world; + this.pending = pending; + this.removed = removed; + this.claims = claims; + this.delayTicks = delayTicks; + } + } +} diff --git a/src/main/java/games/dmg/treefeller/FelledBlockSnapshot.java b/src/main/java/games/dmg/treefeller/FelledBlockSnapshot.java new file mode 100644 index 0000000..16db1e3 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/FelledBlockSnapshot.java @@ -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()); + } +} diff --git a/src/main/java/games/dmg/treefeller/FellingObserver.java b/src/main/java/games/dmg/treefeller/FellingObserver.java new file mode 100644 index 0000000..25e1c04 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/FellingObserver.java @@ -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 removedBlocks); +} diff --git a/src/main/java/games/dmg/treefeller/FellingRecord.java b/src/main/java/games/dmg/treefeller/FellingRecord.java new file mode 100644 index 0000000..ade16c1 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/FellingRecord.java @@ -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 blocks) { + public FellingRecord { + blocks = List.copyOf(blocks); + } +} diff --git a/src/main/java/games/dmg/treefeller/LastFellingStore.java b/src/main/java/games/dmg/treefeller/LastFellingStore.java new file mode 100644 index 0000000..4a3a4ed --- /dev/null +++ b/src/main/java/games/dmg/treefeller/LastFellingStore.java @@ -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 records = new HashMap<>(); + + public LastFellingStore(Clock clock) { + this.clock = clock; + } + + @Override + public void onFelling(Player player, List removedBlocks) { + if (!removedBlocks.isEmpty()) { + records.put( + player.getUniqueId(), + new FellingRecord(player.getUniqueId(), Instant.now(clock), removedBlocks)); + } + } + + public Optional get(UUID playerId) { + return Optional.ofNullable(records.get(playerId)); + } + + public void remove(UUID playerId) { + records.remove(playerId); + } +} diff --git a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java index 6aa7611..b4c9204 100644 --- a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java +++ b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java @@ -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; + } } diff --git a/src/main/java/games/dmg/treefeller/TreeFellingListener.java b/src/main/java/games/dmg/treefeller/TreeFellingListener.java new file mode 100644 index 0000000..23e7d58 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/TreeFellingListener.java @@ -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 detected = detector.detect(event.getBlock()); + if (detected.isEmpty() || !state.isUnlocked(detected.orElseThrow().species())) { + return; + } + starter.start( + player, event.getBlock(), detected.orElseThrow(), animationDelay.getAsInt()); + } +} diff --git a/src/main/java/games/dmg/treefeller/TreeFellingStarter.java b/src/main/java/games/dmg/treefeller/TreeFellingStarter.java new file mode 100644 index 0000000..ea474a3 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/TreeFellingStarter.java @@ -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); +} diff --git a/src/main/java/games/dmg/treefeller/WorldBlockKey.java b/src/main/java/games/dmg/treefeller/WorldBlockKey.java new file mode 100644 index 0000000..852257c --- /dev/null +++ b/src/main/java/games/dmg/treefeller/WorldBlockKey.java @@ -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()); + } +} diff --git a/src/test/java/games/dmg/treefeller/AnimatedTreeFellingEngineTest.java b/src/test/java/games/dmg/treefeller/AnimatedTreeFellingEngineTest.java new file mode 100644 index 0000000..af97bf3 --- /dev/null +++ b/src/test/java/games/dmg/treefeller/AnimatedTreeFellingEngineTest.java @@ -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 completed = new ArrayList<>(); + List 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 tasks = new ArrayDeque<>(); + private final List 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 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; + } + } +} diff --git a/src/test/java/games/dmg/treefeller/TreeFellingListenerTest.java b/src/test/java/games/dmg/treefeller/TreeFellingListenerTest.java new file mode 100644 index 0000000..7eb8d21 --- /dev/null +++ b/src/test/java/games/dmg/treefeller/TreeFellingListenerTest.java @@ -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)); + } +}