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.
|
- 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.
|
- 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`.
|
- 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
|
type: User Story
|
||||||
title: "US-003: Fell unlocked trees"
|
title: "US-003: Fell unlocked trees"
|
||||||
description: Safely and visibly break an unlocked tree's trunk from the mined block upward.
|
description: Safely and visibly break an unlocked tree's trunk from the mined block upward.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-003: Fell unlocked trees
|
# 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
|
## 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.
|
- [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.
|
||||||
- [ ] Sneaking when the initiating block is broken always bypasses automatic felling and leaves the ordinary single-block break intact.
|
- [x] 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.
|
- [x] 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.
|
- [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.
|
||||||
- [ ] 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 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.
|
- [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.
|
||||||
- [ ] Neighbor visitation is deterministic and does not process a location more than once.
|
- [x] 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.
|
- [x] 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.
|
- [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.
|
||||||
- [ ] 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 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.
|
- [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.
|
||||||
- [ ] Axe durability, including Unbreaking behavior, is applied for every successfully felled block without double-charging the initiating break.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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] Only blocks actually removed by this felling are recorded for undo.
|
||||||
|
|
||||||
## Related
|
## 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;
|
package games.dmg.treefeller;
|
||||||
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.time.Clock;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
import org.bukkit.command.PluginCommand;
|
import org.bukkit.command.PluginCommand;
|
||||||
@@ -13,6 +14,8 @@ public final class TreeFellerPlugin extends JavaPlugin {
|
|||||||
private AutomaticBreakRegistry automaticBreakRegistry;
|
private AutomaticBreakRegistry automaticBreakRegistry;
|
||||||
private TreeDetector treeDetector;
|
private TreeDetector treeDetector;
|
||||||
private ProgressBossBarObserver progressBossBarObserver;
|
private ProgressBossBarObserver progressBossBarObserver;
|
||||||
|
private AnimatedTreeFellingEngine fellingEngine;
|
||||||
|
private LastFellingStore lastFellingStore;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -42,6 +45,26 @@ public final class TreeFellerPlugin extends JavaPlugin {
|
|||||||
Level.SEVERE, "Unable to persist Tree Feller progress", exception));
|
Level.SEVERE, "Unable to persist Tree Feller progress", exception));
|
||||||
getServer().getPluginManager().registerEvents(progressListener, this);
|
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(
|
TreeFellerCommand playerCommand = new TreeFellerCommand(
|
||||||
playerStateRepository,
|
playerStateRepository,
|
||||||
exception -> getLogger().log(
|
exception -> getLogger().log(
|
||||||
@@ -60,6 +83,9 @@ public final class TreeFellerPlugin extends JavaPlugin {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
|
if (fellingEngine != null) {
|
||||||
|
fellingEngine.close();
|
||||||
|
}
|
||||||
if (progressBossBarObserver != null) {
|
if (progressBossBarObserver != null) {
|
||||||
progressBossBarObserver.close();
|
progressBossBarObserver.close();
|
||||||
}
|
}
|
||||||
@@ -80,4 +106,8 @@ public final class TreeFellerPlugin extends JavaPlugin {
|
|||||||
public TreeDetector treeDetector() {
|
public TreeDetector treeDetector() {
|
||||||
return 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