feat(undo): restore the latest felled tree safely
This commit is contained in:
@@ -2,6 +2,15 @@
|
||||
|
||||
## 2026-08-11
|
||||
|
||||
### US-005 safe undo completed
|
||||
|
||||
- Added one runtime-only latest-felling record per player with original world, coordinates, material, block-data string, and completion time.
|
||||
- Added `/treefeller undo` behind the separate `treefeller.undo` permission and the configurable six-minute default window.
|
||||
- Undo now preflights worlds, loaded chunks, empty target positions, exact block data, and aggregate inventory materials before making any change.
|
||||
- Missing-material failures report every material and quantity; successful undo withdraws materials once, restores orientation-aware block data without physics, and consumes the record.
|
||||
- Unexpected inventory or world failures roll back changed blocks and inventory where possible and preserve the record while logging suppressed recovery failures for administrators.
|
||||
- Verified shortages, occupied locations, expiration, successful restoration, command reporting, permissions, 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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-005: Undo the last felled tree"
|
||||
description: Safely restore the player's most recent automatic felling without creating replacement materials.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-005: Undo the last felled tree
|
||||
@@ -11,21 +11,21 @@ As a **player**, I want to undo my latest automatically felled tree so that I ca
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/treefeller undo` targets only the issuing player's most recent automatic felling.
|
||||
- [ ] An undo record contains only blocks successfully removed by the operation, including the initiating trunk block when its original state can be captured safely.
|
||||
- [ ] Each record retains the world, block coordinates, original block material, and original block data needed to restore orientation and other supported state.
|
||||
- [ ] Only one undo record is retained per player; a later felling replaces the earlier record after the later operation has removed at least one eligible tree block.
|
||||
- [ ] An undo remains available for six minutes after the felling by default, with a configurable duration.
|
||||
- [ ] Undo performs a complete preflight before changing the world or inventory.
|
||||
- [ ] Preflight requires every target world and chunk to be available and every target position to remain safely restorable; an occupied or otherwise unsafe position fails the whole undo.
|
||||
- [ ] Preflight calculates the exact replacement materials required to reconstruct all recorded trunk states and requires those aggregate materials in the issuing player's inventory.
|
||||
- [ ] If inventory is insufficient, no item or block changes occur and the error lists every missing material with its missing quantity.
|
||||
- [ ] On success, required materials are removed exactly once and all recorded blocks are restored as one logical operation.
|
||||
- [ ] If an unexpected restoration failure occurs after preflight, the implementation avoids a silent partial result and reports the recovery action needed to administrators.
|
||||
- [ ] A successful undo consumes the record so that it cannot be repeated.
|
||||
- [ ] An expired, absent, already-used, or invalid undo produces a clear message and makes no changes.
|
||||
- [ ] Undo restores trunk blocks only; it does not restore foliage, caps, roots, drops, experience, or axe durability.
|
||||
- [ ] Undo has a distinct player permission and never grants access to `/treefelleradmin`.
|
||||
- [x] `/treefeller undo` targets only the issuing player's most recent automatic felling.
|
||||
- [x] An undo record contains only blocks successfully removed by the operation, including the initiating trunk block when its original state can be captured safely.
|
||||
- [x] Each record retains the world, block coordinates, original block material, and original block data needed to restore orientation and other supported state.
|
||||
- [x] Only one undo record is retained per player; a later felling replaces the earlier record after the later operation has removed at least one eligible tree block.
|
||||
- [x] An undo remains available for six minutes after the felling by default, with a configurable duration.
|
||||
- [x] Undo performs a complete preflight before changing the world or inventory.
|
||||
- [x] Preflight requires every target world and chunk to be available and every target position to remain safely restorable; an occupied or otherwise unsafe position fails the whole undo.
|
||||
- [x] Preflight calculates the exact replacement materials required to reconstruct all recorded trunk states and requires those aggregate materials in the issuing player's inventory.
|
||||
- [x] If inventory is insufficient, no item or block changes occur and the error lists every missing material with its missing quantity.
|
||||
- [x] On success, required materials are removed exactly once and all recorded blocks are restored as one logical operation.
|
||||
- [x] If an unexpected restoration failure occurs after preflight, the implementation avoids a silent partial result and reports the recovery action needed to administrators.
|
||||
- [x] A successful undo consumes the record so that it cannot be repeated.
|
||||
- [x] An expired, absent, already-used, or invalid undo produces a clear message and makes no changes.
|
||||
- [x] Undo restores trunk blocks only; it does not restore foliage, caps, roots, drops, experience, or axe durability.
|
||||
- [x] Undo has a distinct player permission and never grants access to `/treefelleradmin`.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -22,16 +22,27 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
|
||||
private final Consumer<Exception> failureHandler;
|
||||
private final Function<String, String> messages;
|
||||
private final ToIntFunction<TreeSpecies> thresholds;
|
||||
private final UndoAction undoAction;
|
||||
|
||||
public TreeFellerCommand(PlayerStateStore states, Consumer<Exception> failureHandler) {
|
||||
this(states, failureHandler, TreeFellerCommand::defaultMessage, ignored -> 100);
|
||||
this(
|
||||
states,
|
||||
failureHandler,
|
||||
TreeFellerCommand::defaultMessage,
|
||||
ignored -> 100,
|
||||
ignored -> UndoResult.of(UndoStatus.NONE, "No felling is available"));
|
||||
}
|
||||
|
||||
public TreeFellerCommand(
|
||||
PlayerStateStore states,
|
||||
Consumer<Exception> failureHandler,
|
||||
Function<String, String> messages) {
|
||||
this(states, failureHandler, messages, ignored -> 100);
|
||||
this(
|
||||
states,
|
||||
failureHandler,
|
||||
messages,
|
||||
ignored -> 100,
|
||||
ignored -> UndoResult.of(UndoStatus.NONE, "No felling is available"));
|
||||
}
|
||||
|
||||
public TreeFellerCommand(
|
||||
@@ -39,10 +50,25 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
|
||||
Consumer<Exception> failureHandler,
|
||||
Function<String, String> messages,
|
||||
ToIntFunction<TreeSpecies> thresholds) {
|
||||
this(
|
||||
states,
|
||||
failureHandler,
|
||||
messages,
|
||||
thresholds,
|
||||
ignored -> UndoResult.of(UndoStatus.NONE, "No felling is available"));
|
||||
}
|
||||
|
||||
public TreeFellerCommand(
|
||||
PlayerStateStore states,
|
||||
Consumer<Exception> failureHandler,
|
||||
Function<String, String> messages,
|
||||
ToIntFunction<TreeSpecies> thresholds,
|
||||
UndoAction undoAction) {
|
||||
this.states = states;
|
||||
this.failureHandler = failureHandler;
|
||||
this.messages = messages;
|
||||
this.thresholds = thresholds;
|
||||
this.undoAction = undoAction;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -56,6 +82,17 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
|
||||
sendUsage(player);
|
||||
return true;
|
||||
}
|
||||
if (arguments[0].equalsIgnoreCase("undo")) {
|
||||
if (arguments.length != 1) {
|
||||
player.sendMessage("Usage: /treefeller undo");
|
||||
} else if (!player.hasPermission("treefeller.undo")) {
|
||||
player.sendMessage("You do not have permission to undo felled trees.");
|
||||
} else {
|
||||
showUndoResult(player, undoAction.undo(player));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
PlayerTreeFellerState state = stateFor(player);
|
||||
if (arguments[0].equalsIgnoreCase("unlocked")) {
|
||||
if (arguments.length != 1) {
|
||||
@@ -121,6 +158,22 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
|
||||
.observeName(player.getName());
|
||||
}
|
||||
|
||||
private void showUndoResult(Player player, UndoResult result) {
|
||||
switch (result.status()) {
|
||||
case SUCCESS -> player.sendMessage("Tree restored successfully.");
|
||||
case NONE -> player.sendMessage(color(messages.apply("no-undo")));
|
||||
case EXPIRED -> player.sendMessage(color(messages.apply("undo-expired")));
|
||||
case MISSING_MATERIALS -> {
|
||||
player.sendMessage("Undo requires additional materials:");
|
||||
result.missingMaterials().entrySet().stream()
|
||||
.sorted(java.util.Map.Entry.comparingByKey())
|
||||
.forEach(entry -> player.sendMessage("- "
|
||||
+ displayMaterial(entry.getKey()) + " x" + entry.getValue()));
|
||||
}
|
||||
case WORLD_UNAVAILABLE, BLOCKED, FAILED -> player.sendMessage(result.detail());
|
||||
}
|
||||
}
|
||||
|
||||
private void showUnlocks(Player player, PlayerTreeFellerState state) {
|
||||
player.sendMessage("Tree Feller species:");
|
||||
for (TreeSpecies species : TreeSpecies.values()) {
|
||||
@@ -159,4 +212,11 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
|
||||
private static String color(String value) {
|
||||
return value.replace('&', '\u00a7');
|
||||
}
|
||||
|
||||
private static String displayMaterial(org.bukkit.Material material) {
|
||||
String[] words = material.name().toLowerCase(Locale.ROOT).split("_");
|
||||
return java.util.Arrays.stream(words)
|
||||
.map(word -> Character.toUpperCase(word.charAt(0)) + word.substring(1))
|
||||
.collect(java.util.stream.Collectors.joining(" "));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
@@ -16,6 +17,7 @@ public final class TreeFellerPlugin extends JavaPlugin {
|
||||
private ProgressBossBarObserver progressBossBarObserver;
|
||||
private AnimatedTreeFellingEngine fellingEngine;
|
||||
private LastFellingStore lastFellingStore;
|
||||
private TreeUndoService undoService;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -65,12 +67,21 @@ public final class TreeFellerPlugin extends JavaPlugin {
|
||||
getServer().getPluginManager().registerEvents(fellingEngine, this);
|
||||
getServer().getPluginManager().registerEvents(fellingListener, this);
|
||||
|
||||
undoService = new TreeUndoService(
|
||||
lastFellingStore,
|
||||
Clock.systemUTC(),
|
||||
getServer()::getWorld,
|
||||
Bukkit::createBlockData,
|
||||
exception -> getLogger().log(
|
||||
Level.SEVERE, "Unable to restore a Tree Feller undo", exception));
|
||||
TreeFellerCommand playerCommand = new TreeFellerCommand(
|
||||
playerStateRepository,
|
||||
exception -> getLogger().log(
|
||||
Level.SEVERE, "Unable to persist Tree Feller preference", exception),
|
||||
key -> settingsService.current().message(key),
|
||||
species -> settingsService.current().threshold(species));
|
||||
species -> settingsService.current().threshold(species),
|
||||
player -> undoService.undo(
|
||||
player, settingsService.current().undoWindowMinutes()));
|
||||
PluginCommand command = Objects.requireNonNull(
|
||||
getCommand("treefeller"), "treefeller command missing from plugin.yml");
|
||||
command.setExecutor(playerCommand);
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
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;
|
||||
|
||||
/** Preflights, accounts for, and atomically restores the latest felling. */
|
||||
public final class TreeUndoService {
|
||||
private final LastFellingStore records;
|
||||
private final Clock clock;
|
||||
private final Function<UUID, World> worlds;
|
||||
private final Function<String, BlockData> blockDataParser;
|
||||
private final Consumer<Exception> failureHandler;
|
||||
|
||||
public TreeUndoService(
|
||||
LastFellingStore records,
|
||||
Clock clock,
|
||||
Function<UUID, World> worlds,
|
||||
Function<String, BlockData> blockDataParser,
|
||||
Consumer<Exception> failureHandler) {
|
||||
this.records = records;
|
||||
this.clock = clock;
|
||||
this.worlds = worlds;
|
||||
this.blockDataParser = blockDataParser;
|
||||
this.failureHandler = failureHandler;
|
||||
}
|
||||
|
||||
public UndoResult undo(Player player, int windowMinutes) {
|
||||
if (windowMinutes < 1) {
|
||||
throw new IllegalArgumentException("windowMinutes must be positive");
|
||||
}
|
||||
UUID playerId = player.getUniqueId();
|
||||
FellingRecord record = records.get(playerId).orElse(null);
|
||||
if (record == null) {
|
||||
return UndoResult.of(UndoStatus.NONE, "No felling is available");
|
||||
}
|
||||
Instant expiresAt = record.felledAt().plus(Duration.ofMinutes(windowMinutes));
|
||||
if (Instant.now(clock).isAfter(expiresAt)) {
|
||||
records.remove(playerId);
|
||||
return UndoResult.of(UndoStatus.EXPIRED, "The undo window has expired");
|
||||
}
|
||||
|
||||
Preflight preflight;
|
||||
try {
|
||||
preflight = preflight(player.getInventory(), record);
|
||||
} catch (RuntimeException exception) {
|
||||
failureHandler.accept(exception);
|
||||
return UndoResult.of(UndoStatus.FAILED, "Undo preflight failed");
|
||||
}
|
||||
if (preflight.failure != null) {
|
||||
return preflight.failure;
|
||||
}
|
||||
|
||||
List<Restoration> changed = new ArrayList<>();
|
||||
try {
|
||||
preflight.withdrawal.apply(player.getInventory());
|
||||
for (Restoration restoration : preflight.restorations) {
|
||||
restoration.block.setBlockData(restoration.target, false);
|
||||
changed.add(restoration);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
rollback(player.getInventory(), preflight.withdrawal, changed, exception);
|
||||
return UndoResult.of(UndoStatus.FAILED, "Undo restoration failed and was rolled back");
|
||||
}
|
||||
|
||||
records.remove(playerId);
|
||||
return UndoResult.of(UndoStatus.SUCCESS, "Tree restored");
|
||||
}
|
||||
|
||||
private Preflight preflight(PlayerInventory inventory, FellingRecord record) {
|
||||
List<Restoration> restorations = new ArrayList<>();
|
||||
EnumMap<Material, Integer> required = new EnumMap<>(Material.class);
|
||||
for (FelledBlockSnapshot snapshot : record.blocks()) {
|
||||
World world = worlds.apply(snapshot.worldId());
|
||||
if (world == null) {
|
||||
return Preflight.failure(UndoResult.of(
|
||||
UndoStatus.WORLD_UNAVAILABLE,
|
||||
"World " + snapshot.worldName() + " is unavailable"));
|
||||
}
|
||||
if (!world.isChunkLoaded(snapshot.x() >> 4, snapshot.z() >> 4)) {
|
||||
return Preflight.failure(UndoResult.of(
|
||||
UndoStatus.WORLD_UNAVAILABLE,
|
||||
"A required chunk in " + snapshot.worldName() + " is not loaded"));
|
||||
}
|
||||
Block block = world.getBlockAt(snapshot.x(), snapshot.y(), snapshot.z());
|
||||
if (!block.isEmpty()) {
|
||||
return Preflight.failure(UndoResult.of(
|
||||
UndoStatus.BLOCKED,
|
||||
"Restoration position is occupied at "
|
||||
+ snapshot.x() + "," + snapshot.y() + "," + snapshot.z()));
|
||||
}
|
||||
BlockData target = blockDataParser.apply(snapshot.blockData());
|
||||
restorations.add(new Restoration(block, block.getBlockData().clone(), target));
|
||||
required.merge(snapshot.material(), 1, Math::addExact);
|
||||
}
|
||||
|
||||
InventoryWithdrawal withdrawal = InventoryWithdrawal.plan(inventory, required);
|
||||
if (!withdrawal.missing.isEmpty()) {
|
||||
return Preflight.failure(UndoResult.missing(withdrawal.missing));
|
||||
}
|
||||
return new Preflight(List.copyOf(restorations), withdrawal, null);
|
||||
}
|
||||
|
||||
private void rollback(
|
||||
PlayerInventory inventory,
|
||||
InventoryWithdrawal withdrawal,
|
||||
List<Restoration> changed,
|
||||
RuntimeException originalFailure) {
|
||||
RuntimeException failure = originalFailure;
|
||||
for (int index = changed.size() - 1; index >= 0; index--) {
|
||||
Restoration restoration = changed.get(index);
|
||||
try {
|
||||
restoration.block.setBlockData(restoration.prior, false);
|
||||
} catch (RuntimeException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
}
|
||||
try {
|
||||
withdrawal.rollback(inventory);
|
||||
} catch (RuntimeException rollbackFailure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
}
|
||||
failureHandler.accept(failure);
|
||||
}
|
||||
|
||||
private record Restoration(Block block, BlockData prior, BlockData target) {
|
||||
}
|
||||
|
||||
private record Preflight(
|
||||
List<Restoration> restorations,
|
||||
InventoryWithdrawal withdrawal,
|
||||
UndoResult failure) {
|
||||
private static Preflight failure(UndoResult result) {
|
||||
return new Preflight(List.of(), InventoryWithdrawal.empty(), result);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InventoryWithdrawal {
|
||||
private final Map<Integer, ItemStack> originals;
|
||||
private final Map<Integer, ItemStack> replacements;
|
||||
private final Map<Material, Integer> missing;
|
||||
|
||||
private InventoryWithdrawal(
|
||||
Map<Integer, ItemStack> originals,
|
||||
Map<Integer, ItemStack> replacements,
|
||||
Map<Material, Integer> missing) {
|
||||
this.originals = originals;
|
||||
this.replacements = replacements;
|
||||
this.missing = missing;
|
||||
}
|
||||
|
||||
private static InventoryWithdrawal plan(
|
||||
PlayerInventory inventory, Map<Material, Integer> required) {
|
||||
ItemStack[] contents = inventory.getStorageContents();
|
||||
EnumMap<Material, Integer> remaining = new EnumMap<>(Material.class);
|
||||
remaining.putAll(required);
|
||||
Map<Integer, ItemStack> originals = new HashMap<>();
|
||||
Map<Integer, ItemStack> replacements = new HashMap<>();
|
||||
|
||||
for (int index = 0; index < contents.length; index++) {
|
||||
ItemStack stack = contents[index];
|
||||
if (stack == null || isAir(stack.getType())) {
|
||||
continue;
|
||||
}
|
||||
int needed = remaining.getOrDefault(stack.getType(), 0);
|
||||
if (needed <= 0) {
|
||||
continue;
|
||||
}
|
||||
int taken = Math.min(needed, stack.getAmount());
|
||||
originals.put(index, stack.clone());
|
||||
if (taken == stack.getAmount()) {
|
||||
replacements.put(index, null);
|
||||
} else {
|
||||
ItemStack reduced = stack.clone();
|
||||
reduced.setAmount(stack.getAmount() - taken);
|
||||
replacements.put(index, reduced);
|
||||
}
|
||||
remaining.put(stack.getType(), needed - taken);
|
||||
}
|
||||
|
||||
EnumMap<Material, Integer> missing = new EnumMap<>(Material.class);
|
||||
remaining.forEach((material, count) -> {
|
||||
if (count > 0) {
|
||||
missing.put(material, count);
|
||||
}
|
||||
});
|
||||
return new InventoryWithdrawal(originals, replacements, Map.copyOf(missing));
|
||||
}
|
||||
|
||||
private static InventoryWithdrawal empty() {
|
||||
return new InventoryWithdrawal(Map.of(), Map.of(), Map.of());
|
||||
}
|
||||
|
||||
private static boolean isAir(Material material) {
|
||||
return material == Material.AIR
|
||||
|| material == Material.CAVE_AIR
|
||||
|| material == Material.VOID_AIR;
|
||||
}
|
||||
|
||||
private void apply(PlayerInventory inventory) {
|
||||
replacements.forEach(inventory::setItem);
|
||||
}
|
||||
|
||||
private void rollback(PlayerInventory inventory) {
|
||||
originals.forEach(inventory::setItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Player-command boundary for undo execution. */
|
||||
@FunctionalInterface
|
||||
public interface UndoAction {
|
||||
UndoResult undo(Player player);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.Map;
|
||||
import org.bukkit.Material;
|
||||
|
||||
/** Complete result of an undo request without partial-success semantics. */
|
||||
public record UndoResult(
|
||||
UndoStatus status,
|
||||
Map<Material, Integer> missingMaterials,
|
||||
String detail) {
|
||||
public UndoResult {
|
||||
missingMaterials = Map.copyOf(missingMaterials);
|
||||
}
|
||||
|
||||
public static UndoResult of(UndoStatus status, String detail) {
|
||||
return new UndoResult(status, Map.of(), detail);
|
||||
}
|
||||
|
||||
public static UndoResult missing(Map<Material, Integer> missing) {
|
||||
return new UndoResult(
|
||||
UndoStatus.MISSING_MATERIALS, missing, "Required replacement materials are missing");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
/** Observable outcomes of an atomic tree undo request. */
|
||||
public enum UndoStatus {
|
||||
SUCCESS,
|
||||
NONE,
|
||||
EXPIRED,
|
||||
WORLD_UNAVAILABLE,
|
||||
BLOCKED,
|
||||
MISSING_MATERIALS,
|
||||
FAILED
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -74,6 +76,22 @@ class TreeFellerCommandTest {
|
||||
verify(player, atLeastOnce()).sendMessage(contains("Mushroom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsTheExactInventoryShortageForUndo() {
|
||||
Player player = mock(Player.class);
|
||||
when(player.hasPermission("treefeller.undo")).thenReturn(true);
|
||||
TreeFellerCommand handler = new TreeFellerCommand(
|
||||
new InMemoryStateStore(),
|
||||
ignored -> { },
|
||||
ignored -> "message",
|
||||
ignored -> 100,
|
||||
ignored -> UndoResult.missing(Map.of(Material.OAK_LOG, 3)));
|
||||
|
||||
handler.onCommand(player, mock(Command.class), "treefeller", new String[] {"undo"});
|
||||
|
||||
verify(player).sendMessage(contains("Oak Log x3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesOnlyPlayerCommandSyntaxByArgumentPosition() {
|
||||
TreeFellerCommand handler = new TreeFellerCommand(new InMemoryStateStore(), ignored -> { });
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.nullable;
|
||||
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.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
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 TreeUndoServiceTest {
|
||||
private static final Instant FELLED_AT = Instant.parse("2026-08-11T20:00:00Z");
|
||||
|
||||
@Test
|
||||
void reportsEveryMissingInventoryMaterialWithoutChangingTheWorld() {
|
||||
Fixture fixture = new Fixture(FELLED_AT.plusSeconds(60));
|
||||
fixture.record(List.of(
|
||||
fixture.snapshot(0, Material.OAK_LOG),
|
||||
fixture.snapshot(1, Material.OAK_LOG),
|
||||
fixture.snapshot(2, Material.BIRCH_LOG)));
|
||||
when(fixture.inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.OAK_LOG, 1)
|
||||
});
|
||||
|
||||
UndoResult result = fixture.service().undo(fixture.player, 6);
|
||||
|
||||
assertEquals(UndoStatus.MISSING_MATERIALS, result.status());
|
||||
assertEquals(1, result.missingMaterials().get(Material.OAK_LOG));
|
||||
assertEquals(1, result.missingMaterials().get(Material.BIRCH_LOG));
|
||||
verify(fixture.block, never()).setBlockData(any(BlockData.class), anyBoolean());
|
||||
assertTrue(fixture.store.get(fixture.playerId).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void atomicallyConsumesMaterialsRestoresBlockDataAndConsumesTheRecord() {
|
||||
Fixture fixture = new Fixture(FELLED_AT.plusSeconds(60));
|
||||
fixture.record(List.of(fixture.snapshot(0, Material.OAK_LOG)));
|
||||
when(fixture.inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.OAK_LOG, 1)
|
||||
});
|
||||
|
||||
UndoResult result = fixture.service().undo(fixture.player, 6);
|
||||
|
||||
assertEquals(UndoStatus.SUCCESS, result.status());
|
||||
verify(fixture.inventory).setItem(0, null);
|
||||
verify(fixture.block).setBlockData(fixture.restoredData, false);
|
||||
assertTrue(fixture.store.get(fixture.playerId).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesTheWholeUndoWhenAnyRestorationPositionIsOccupied() {
|
||||
Fixture fixture = new Fixture(FELLED_AT.plusSeconds(60));
|
||||
fixture.record(List.of(fixture.snapshot(0, Material.OAK_LOG)));
|
||||
when(fixture.block.isEmpty()).thenReturn(false);
|
||||
when(fixture.inventory.getStorageContents()).thenReturn(new ItemStack[] {
|
||||
new ItemStack(Material.OAK_LOG, 1)
|
||||
});
|
||||
|
||||
UndoResult result = fixture.service().undo(fixture.player, 6);
|
||||
|
||||
assertEquals(UndoStatus.BLOCKED, result.status());
|
||||
verify(fixture.inventory, never()).setItem(anyInt(), nullable(ItemStack.class));
|
||||
verify(fixture.block, never()).setBlockData(any(BlockData.class), anyBoolean());
|
||||
assertTrue(fixture.store.get(fixture.playerId).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expiresTheRecordAfterTheConfiguredWindow() {
|
||||
Fixture fixture = new Fixture(FELLED_AT.plusSeconds(361));
|
||||
fixture.record(List.of(fixture.snapshot(0, Material.OAK_LOG)));
|
||||
|
||||
UndoResult result = fixture.service().undo(fixture.player, 6);
|
||||
|
||||
assertEquals(UndoStatus.EXPIRED, result.status());
|
||||
assertTrue(fixture.store.get(fixture.playerId).isEmpty());
|
||||
}
|
||||
|
||||
private static final class Fixture {
|
||||
private final UUID playerId = UUID.randomUUID();
|
||||
private final Clock fellingClock = Clock.fixed(FELLED_AT, ZoneOffset.UTC);
|
||||
private final Clock undoClock;
|
||||
private final LastFellingStore store = new LastFellingStore(fellingClock);
|
||||
private final Player player = mock(Player.class);
|
||||
private final PlayerInventory inventory = mock(PlayerInventory.class);
|
||||
private final World world = mock(World.class);
|
||||
private final Block block = mock(Block.class);
|
||||
private final BlockData emptyData = mock(BlockData.class);
|
||||
private final BlockData restoredData = mock(BlockData.class);
|
||||
|
||||
private Fixture(Instant now) {
|
||||
undoClock = Clock.fixed(now, ZoneOffset.UTC);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getInventory()).thenReturn(inventory);
|
||||
when(world.getUID()).thenReturn(UUID.randomUUID());
|
||||
when(world.isChunkLoaded(anyInt(), anyInt())).thenReturn(true);
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt()))
|
||||
.thenReturn(block);
|
||||
when(block.isEmpty()).thenReturn(true);
|
||||
when(block.getBlockData()).thenReturn(emptyData);
|
||||
}
|
||||
|
||||
private void record(List<FelledBlockSnapshot> snapshots) {
|
||||
store.onFelling(player, snapshots);
|
||||
}
|
||||
|
||||
private FelledBlockSnapshot snapshot(int y, Material material) {
|
||||
return new FelledBlockSnapshot(
|
||||
world.getUID(), "world", 0, 64 + y, 0, material, material.name());
|
||||
}
|
||||
|
||||
private TreeUndoService service() {
|
||||
return new TreeUndoService(
|
||||
store,
|
||||
undoClock,
|
||||
ignored -> world,
|
||||
ignored -> restoredData,
|
||||
ignored -> { });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user