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
@@ -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
}