feat(undo): restore the latest felled tree safely
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user