diff --git a/design/log.md b/design/log.md index f8c84be..9924f15 100644 --- a/design/log.md +++ b/design/log.md @@ -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. diff --git a/design/user-stories/us-005-undo-the-last-felled-tree.md b/design/user-stories/us-005-undo-the-last-felled-tree.md index 3f1d9af..96cffbe 100644 --- a/design/user-stories/us-005-undo-the-last-felled-tree.md +++ b/design/user-stories/us-005-undo-the-last-felled-tree.md @@ -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 diff --git a/src/main/java/games/dmg/treefeller/TreeFellerCommand.java b/src/main/java/games/dmg/treefeller/TreeFellerCommand.java index b33c786..8aed09c 100644 --- a/src/main/java/games/dmg/treefeller/TreeFellerCommand.java +++ b/src/main/java/games/dmg/treefeller/TreeFellerCommand.java @@ -22,16 +22,27 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter { private final Consumer failureHandler; private final Function messages; private final ToIntFunction thresholds; + private final UndoAction undoAction; public TreeFellerCommand(PlayerStateStore states, Consumer 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 failureHandler, Function 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 failureHandler, Function messages, ToIntFunction thresholds) { + this( + states, + failureHandler, + messages, + thresholds, + ignored -> UndoResult.of(UndoStatus.NONE, "No felling is available")); + } + + public TreeFellerCommand( + PlayerStateStore states, + Consumer failureHandler, + Function messages, + ToIntFunction 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(" ")); + } } diff --git a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java index b4c9204..c3d5f4f 100644 --- a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java +++ b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java @@ -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); diff --git a/src/main/java/games/dmg/treefeller/TreeUndoService.java b/src/main/java/games/dmg/treefeller/TreeUndoService.java new file mode 100644 index 0000000..f9dc08b --- /dev/null +++ b/src/main/java/games/dmg/treefeller/TreeUndoService.java @@ -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 worlds; + private final Function blockDataParser; + private final Consumer failureHandler; + + public TreeUndoService( + LastFellingStore records, + Clock clock, + Function worlds, + Function blockDataParser, + Consumer 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 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 restorations = new ArrayList<>(); + EnumMap 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 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 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 originals; + private final Map replacements; + private final Map missing; + + private InventoryWithdrawal( + Map originals, + Map replacements, + Map missing) { + this.originals = originals; + this.replacements = replacements; + this.missing = missing; + } + + private static InventoryWithdrawal plan( + PlayerInventory inventory, Map required) { + ItemStack[] contents = inventory.getStorageContents(); + EnumMap remaining = new EnumMap<>(Material.class); + remaining.putAll(required); + Map originals = new HashMap<>(); + Map 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 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); + } + } +} diff --git a/src/main/java/games/dmg/treefeller/UndoAction.java b/src/main/java/games/dmg/treefeller/UndoAction.java new file mode 100644 index 0000000..e3d983c --- /dev/null +++ b/src/main/java/games/dmg/treefeller/UndoAction.java @@ -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); +} diff --git a/src/main/java/games/dmg/treefeller/UndoResult.java b/src/main/java/games/dmg/treefeller/UndoResult.java new file mode 100644 index 0000000..4c2c7dc --- /dev/null +++ b/src/main/java/games/dmg/treefeller/UndoResult.java @@ -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 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 missing) { + return new UndoResult( + UndoStatus.MISSING_MATERIALS, missing, "Required replacement materials are missing"); + } +} diff --git a/src/main/java/games/dmg/treefeller/UndoStatus.java b/src/main/java/games/dmg/treefeller/UndoStatus.java new file mode 100644 index 0000000..0a77caa --- /dev/null +++ b/src/main/java/games/dmg/treefeller/UndoStatus.java @@ -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 +} diff --git a/src/test/java/games/dmg/treefeller/TreeFellerCommandTest.java b/src/test/java/games/dmg/treefeller/TreeFellerCommandTest.java index 00bb3be..6d9d910 100644 --- a/src/test/java/games/dmg/treefeller/TreeFellerCommandTest.java +++ b/src/test/java/games/dmg/treefeller/TreeFellerCommandTest.java @@ -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 -> { }); diff --git a/src/test/java/games/dmg/treefeller/TreeUndoServiceTest.java b/src/test/java/games/dmg/treefeller/TreeUndoServiceTest.java new file mode 100644 index 0000000..fbffd2f --- /dev/null +++ b/src/test/java/games/dmg/treefeller/TreeUndoServiceTest.java @@ -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 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 -> { }); + } + } +}