feat(felling): animate safe unlocked tree breaks
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user