feat(undo): restore the latest felled tree safely

This commit is contained in:
dmg
2026-08-11 17:32:41 -04:00
parent 17f1631b96
commit 8c7c008ca0
10 changed files with 521 additions and 19 deletions
+9
View File
@@ -2,6 +2,15 @@
## 2026-08-11 ## 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 ### US-003 animated tree felling completed
- Added eligibility-aware automatic felling for unlocked species with sneaking, saved preference, and administrative-lock bypasses. - Added eligibility-aware automatic felling for unlocked species with sneaking, saved preference, and administrative-lock bypasses.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-005: Undo the last felled tree" title: "US-005: Undo the last felled tree"
description: Safely restore the player's most recent automatic felling without creating replacement materials. 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 # 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 ## Acceptance criteria
- [ ] `/treefeller undo` targets only the issuing player's most recent automatic felling. - [x] `/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. - [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.
- [ ] Each record retains the world, block coordinates, original block material, and original block data needed to restore orientation and other supported state. - [x] 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. - [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.
- [ ] An undo remains available for six minutes after the felling by default, with a configurable duration. - [x] 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. - [x] 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. - [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.
- [ ] 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] 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. - [x] 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. - [x] 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. - [x] 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. - [x] 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. - [x] 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. - [x] 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] Undo has a distinct player permission and never grants access to `/treefelleradmin`.
## Related ## Related
@@ -22,16 +22,27 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
private final Consumer<Exception> failureHandler; private final Consumer<Exception> failureHandler;
private final Function<String, String> messages; private final Function<String, String> messages;
private final ToIntFunction<TreeSpecies> thresholds; private final ToIntFunction<TreeSpecies> thresholds;
private final UndoAction undoAction;
public TreeFellerCommand(PlayerStateStore states, Consumer<Exception> failureHandler) { 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( public TreeFellerCommand(
PlayerStateStore states, PlayerStateStore states,
Consumer<Exception> failureHandler, Consumer<Exception> failureHandler,
Function<String, String> messages) { 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( public TreeFellerCommand(
@@ -39,10 +50,25 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
Consumer<Exception> failureHandler, Consumer<Exception> failureHandler,
Function<String, String> messages, Function<String, String> messages,
ToIntFunction<TreeSpecies> thresholds) { 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.states = states;
this.failureHandler = failureHandler; this.failureHandler = failureHandler;
this.messages = messages; this.messages = messages;
this.thresholds = thresholds; this.thresholds = thresholds;
this.undoAction = undoAction;
} }
@Override @Override
@@ -56,6 +82,17 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
sendUsage(player); sendUsage(player);
return true; 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); PlayerTreeFellerState state = stateFor(player);
if (arguments[0].equalsIgnoreCase("unlocked")) { if (arguments[0].equalsIgnoreCase("unlocked")) {
if (arguments.length != 1) { if (arguments.length != 1) {
@@ -121,6 +158,22 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
.observeName(player.getName()); .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) { private void showUnlocks(Player player, PlayerTreeFellerState state) {
player.sendMessage("Tree Feller species:"); player.sendMessage("Tree Feller species:");
for (TreeSpecies species : TreeSpecies.values()) { for (TreeSpecies species : TreeSpecies.values()) {
@@ -159,4 +212,11 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
private static String color(String value) { private static String color(String value) {
return value.replace('&', '\u00a7'); 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.time.Clock;
import java.util.Objects; import java.util.Objects;
import java.util.logging.Level; import java.util.logging.Level;
import org.bukkit.Bukkit;
import org.bukkit.command.PluginCommand; import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
@@ -16,6 +17,7 @@ public final class TreeFellerPlugin extends JavaPlugin {
private ProgressBossBarObserver progressBossBarObserver; private ProgressBossBarObserver progressBossBarObserver;
private AnimatedTreeFellingEngine fellingEngine; private AnimatedTreeFellingEngine fellingEngine;
private LastFellingStore lastFellingStore; private LastFellingStore lastFellingStore;
private TreeUndoService undoService;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -65,12 +67,21 @@ public final class TreeFellerPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(fellingEngine, this); getServer().getPluginManager().registerEvents(fellingEngine, this);
getServer().getPluginManager().registerEvents(fellingListener, 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( TreeFellerCommand playerCommand = new TreeFellerCommand(
playerStateRepository, playerStateRepository,
exception -> getLogger().log( exception -> getLogger().log(
Level.SEVERE, "Unable to persist Tree Feller preference", exception), Level.SEVERE, "Unable to persist Tree Feller preference", exception),
key -> settingsService.current().message(key), 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( PluginCommand command = Objects.requireNonNull(
getCommand("treefeller"), "treefeller command missing from plugin.yml"); getCommand("treefeller"), "treefeller command missing from plugin.yml");
command.setExecutor(playerCommand); 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 static org.mockito.Mockito.when;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.bukkit.Material;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -74,6 +76,22 @@ class TreeFellerCommandTest {
verify(player, atLeastOnce()).sendMessage(contains("Mushroom")); 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 @Test
void completesOnlyPlayerCommandSyntaxByArgumentPosition() { void completesOnlyPlayerCommandSyntaxByArgumentPosition() {
TreeFellerCommand handler = new TreeFellerCommand(new InMemoryStateStore(), ignored -> { }); 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 -> { });
}
}
}