feat(admin): add Tree Feller administration

This commit is contained in:
dmg
2026-08-11 17:37:56 -04:00
parent 2664f7c68b
commit 206096efc2
14 changed files with 710 additions and 21 deletions
@@ -0,0 +1,8 @@
package games.dmg.treefeller;
import java.util.List;
/** Player state store that also supports administrative discovery. */
public interface PlayerStateCatalog extends PlayerStateStore {
List<PlayerTreeFellerState> loadAll();
}
@@ -0,0 +1,87 @@
package games.dmg.treefeller;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.Server;
import org.bukkit.entity.Player;
/** Resolves exact live names, durable aliases, and authoritative UUIDs safely. */
public final class PlayerTargetResolver {
private final Server server;
private final PlayerStateCatalog states;
public PlayerTargetResolver(Server server, PlayerStateCatalog states) {
this.server = server;
this.states = states;
}
public TargetResolution resolve(String value) {
Optional<UUID> parsedId = parseUuid(value);
if (parsedId.isPresent()) {
UUID playerId = parsedId.orElseThrow();
Player online = server.getPlayer(playerId);
Optional<PlayerTreeFellerState> saved = states.load(playerId);
if (saved.isEmpty() && online == null) {
return TargetResolution.notFound();
}
PlayerTreeFellerState state = saved.orElseGet(() ->
PlayerTreeFellerState.initial(playerId, online.getName()));
return TargetResolution.found(toTarget(state, online));
}
for (Player online : server.getOnlinePlayers()) {
if (online.getName().equalsIgnoreCase(value)) {
PlayerTreeFellerState state = states.load(online.getUniqueId())
.orElseGet(() -> PlayerTreeFellerState.initial(
online.getUniqueId(), online.getName()))
.observeName(online.getName());
return TargetResolution.found(toTarget(state, online));
}
}
String normalized = value.toLowerCase(Locale.ROOT);
List<PlayerTreeFellerState> matches = states.loadAll().stream()
.filter(state -> state.knownNames().stream()
.anyMatch(name -> name.toLowerCase(Locale.ROOT).equals(normalized)))
.toList();
if (matches.isEmpty()) {
return TargetResolution.notFound();
}
if (matches.size() > 1) {
return TargetResolution.ambiguous();
}
PlayerTreeFellerState state = matches.get(0);
return TargetResolution.found(toTarget(state, server.getPlayer(state.playerId())));
}
public List<String> suggestions() {
Set<String> names = new LinkedHashSet<>();
server.getOnlinePlayers().stream()
.map(Player::getName)
.sorted(String.CASE_INSENSITIVE_ORDER)
.forEach(names::add);
states.loadAll().stream()
.map(PlayerTreeFellerState::latestName)
.sorted(String.CASE_INSENSITIVE_ORDER)
.forEach(names::add);
return List.copyOf(new ArrayList<>(names));
}
private ResolvedPlayer toTarget(PlayerTreeFellerState state, Player online) {
String name = online == null ? state.latestName() : online.getName();
return new ResolvedPlayer(state.playerId(), name, state, online);
}
private Optional<UUID> parseUuid(String value) {
try {
return Optional.of(UUID.fromString(value));
} catch (IllegalArgumentException exception) {
return Optional.empty();
}
}
}
@@ -0,0 +1,12 @@
package games.dmg.treefeller;
import java.util.UUID;
import org.bukkit.entity.Player;
/** Unambiguous administrative player identity and optional live connection. */
public record ResolvedPlayer(
UUID playerId,
String displayName,
PlayerTreeFellerState state,
Player onlinePlayer) {
}
@@ -0,0 +1,16 @@
package games.dmg.treefeller;
/** Result of permission-independent administrative identity lookup. */
public record TargetResolution(TargetResolutionStatus status, ResolvedPlayer target) {
public static TargetResolution found(ResolvedPlayer target) {
return new TargetResolution(TargetResolutionStatus.FOUND, target);
}
public static TargetResolution notFound() {
return new TargetResolution(TargetResolutionStatus.NOT_FOUND, null);
}
public static TargetResolution ambiguous() {
return new TargetResolution(TargetResolutionStatus.AMBIGUOUS, null);
}
}
@@ -0,0 +1,8 @@
package games.dmg.treefeller;
/** Player target lookup result. */
public enum TargetResolutionStatus {
FOUND,
NOT_FOUND,
AMBIGUOUS
}
@@ -0,0 +1,289 @@
package games.dmg.treefeller;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.function.Consumer;
import org.bukkit.Server;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
/** Permission-aware `/treefelleradmin` command tree. */
public final class TreeFellerAdminCommand implements CommandExecutor, TabCompleter {
private static final List<String> ROOTS = List.of("player", "threshold");
private static final List<String> PLAYER_PROPERTIES = List.of("locked", "status", "tree");
private static final List<String> BOOLEAN_VALUES = List.of("off", "on");
private static final List<String> TREE_ACTIONS = List.of("grant", "reset");
private final PlayerStateCatalog states;
private final TreeFellerSettingsService settings;
private final Consumer<Exception> failureHandler;
private final PlayerTargetResolver targets;
public TreeFellerAdminCommand(
Server server,
PlayerStateCatalog states,
TreeFellerSettingsService settings,
Consumer<Exception> failureHandler) {
this.states = states;
this.settings = settings;
this.failureHandler = failureHandler;
this.targets = new PlayerTargetResolver(server, states);
}
@Override
public boolean onCommand(
CommandSender sender, Command command, String label, String[] arguments) {
if (!sender.hasPermission("treefeller.admin")) {
sender.sendMessage("You do not have permission to administer Tree Feller.");
return true;
}
if (arguments.length == 0) {
sendUsage(sender);
return true;
}
if (arguments[0].equalsIgnoreCase("threshold")) {
changeThreshold(sender, arguments);
return true;
}
if (arguments[0].equalsIgnoreCase("player")) {
administerPlayer(sender, arguments);
return true;
}
sendUsage(sender);
return true;
}
@Override
public List<String> onTabComplete(
CommandSender sender, Command command, String alias, String[] arguments) {
if (!sender.hasPermission("treefeller.admin")) {
return List.of();
}
if (arguments.length == 1) {
return matching(ROOTS, arguments[0]);
}
if (arguments[0].equalsIgnoreCase("threshold")) {
if (arguments.length == 2) {
return matching(treeIdentifiers(), arguments[1]);
}
return List.of();
}
if (!arguments[0].equalsIgnoreCase("player")) {
return List.of();
}
if (arguments.length == 2) {
return matching(targets.suggestions(), arguments[1]);
}
if (arguments.length == 3) {
return matching(PLAYER_PROPERTIES, arguments[2]);
}
if (arguments.length == 4 && arguments[2].equalsIgnoreCase("locked")) {
return matching(BOOLEAN_VALUES, arguments[3]);
}
if (arguments.length == 4 && arguments[2].equalsIgnoreCase("tree")) {
return matching(treeIdentifiers(), arguments[3]);
}
if (arguments.length == 5 && arguments[2].equalsIgnoreCase("tree")) {
return matching(TREE_ACTIONS, arguments[4]);
}
return List.of();
}
private void changeThreshold(CommandSender sender, String[] arguments) {
if (arguments.length != 3) {
sender.sendMessage("Usage: /treefelleradmin threshold <type> <blocks>");
return;
}
Optional<TreeSpecies> species = TreeSpecies.fromId(arguments[1]);
if (species.isEmpty()) {
sender.sendMessage("Unknown tree type: " + arguments[1]);
return;
}
int threshold;
try {
threshold = Integer.parseInt(arguments[2]);
} catch (NumberFormatException exception) {
sender.sendMessage("Threshold must be a whole number.");
return;
}
TreeSpecies tree = species.orElseThrow();
int current = settings.current().threshold(tree);
if (current == threshold) {
sender.sendMessage(tree.displayName() + " already requires " + threshold + " blocks.");
return;
}
try {
settings.changeThreshold(tree, threshold);
sender.sendMessage(tree.displayName() + " threshold changed from "
+ current + " to " + threshold + " blocks.");
} catch (IllegalArgumentException exception) {
sender.sendMessage(exception.getMessage());
} catch (IOException exception) {
failureHandler.accept(exception);
sender.sendMessage("The threshold could not be saved; no change was applied.");
}
}
private void administerPlayer(CommandSender sender, String[] arguments) {
if (arguments.length < 3) {
sendUsage(sender);
return;
}
TargetResolution resolution = targets.resolve(arguments[1]);
if (resolution.status() == TargetResolutionStatus.NOT_FOUND) {
sender.sendMessage("Player not found: " + arguments[1]);
return;
}
if (resolution.status() == TargetResolutionStatus.AMBIGUOUS) {
sender.sendMessage("Player name is ambiguous; use the player's UUID.");
return;
}
ResolvedPlayer target = resolution.target();
switch (arguments[2].toLowerCase(Locale.ROOT)) {
case "status" -> showStatus(sender, target, arguments);
case "locked" -> changeLock(sender, target, arguments);
case "tree" -> changeTree(sender, target, arguments);
default -> sendUsage(sender);
}
}
private void showStatus(CommandSender sender, ResolvedPlayer target, String[] arguments) {
if (arguments.length != 3) {
sender.sendMessage("Usage: /treefelleradmin player <name|uuid> status");
return;
}
PlayerTreeFellerState state = target.state();
sender.sendMessage("Tree Feller status for " + target.displayName()
+ " (" + target.playerId() + "):");
sender.sendMessage("- preference: " + (state.enabled() ? "enabled" : "disabled"));
sender.sendMessage("- administrative lock: " + (state.locked() ? "on" : "off"));
for (TreeSpecies species : TreeSpecies.values()) {
sender.sendMessage("- " + species.displayName() + ": "
+ (state.isUnlocked(species) ? "unlocked" : "locked")
+ " (" + state.progress(species) + "/"
+ settings.current().threshold(species) + ")");
}
}
private void changeLock(CommandSender sender, ResolvedPlayer target, String[] arguments) {
if (arguments.length != 4) {
sender.sendMessage("Usage: /treefelleradmin player <name|uuid> locked <on|off>");
return;
}
Optional<Boolean> requested = parseBoolean(arguments[3]);
if (requested.isEmpty()) {
sender.sendMessage("Lock value must be on or off.");
return;
}
boolean locked = requested.orElseThrow();
PlayerTreeFellerState state = target.state();
if (state.locked() == locked) {
sender.sendMessage(target.displayName() + " is already "
+ (locked ? "locked" : "unlocked") + ".");
return;
}
PlayerTreeFellerState changed = state.withLocked(locked);
if (save(sender, changed)) {
sender.sendMessage("Automatic felling for " + target.displayName() + " is now "
+ (locked ? "locked" : "unlocked") + ".");
notifyTarget(target, "An administrator "
+ (locked ? "locked" : "unlocked") + " automatic tree felling for you.");
}
}
private void changeTree(CommandSender sender, ResolvedPlayer target, String[] arguments) {
if (arguments.length != 5) {
sender.sendMessage(
"Usage: /treefelleradmin player <name|uuid> tree <type> <grant|reset>");
return;
}
Optional<TreeSpecies> parsedSpecies = TreeSpecies.fromId(arguments[3]);
if (parsedSpecies.isEmpty()) {
sender.sendMessage("Unknown tree type: " + arguments[3]);
return;
}
TreeSpecies species = parsedSpecies.orElseThrow();
PlayerTreeFellerState state = target.state();
if (arguments[4].equalsIgnoreCase("grant")) {
if (state.isUnlocked(species)) {
sender.sendMessage(target.displayName() + " already has "
+ species.displayName() + " unlocked.");
return;
}
if (save(sender, state.withUnlocked(species, true))) {
sender.sendMessage("Granted " + species.displayName() + " felling to "
+ target.displayName() + ".");
notifyTarget(target, "An administrator granted "
+ species.displayName() + " Tree Feller access.");
}
return;
}
if (arguments[4].equalsIgnoreCase("reset")) {
if (!state.isUnlocked(species) && state.progress(species) == 0L) {
sender.sendMessage(target.displayName() + " already has no "
+ species.displayName() + " progress.");
return;
}
PlayerTreeFellerState changed = state
.withUnlocked(species, false)
.withProgress(species, 0L);
if (save(sender, changed)) {
sender.sendMessage("Reset " + species.displayName() + " progress for "
+ target.displayName() + ".");
notifyTarget(target, "An administrator reset your "
+ species.displayName() + " Tree Feller progress.");
}
return;
}
sender.sendMessage("Tree action must be grant or reset.");
}
private boolean save(CommandSender sender, PlayerTreeFellerState state) {
try {
states.save(state);
return true;
} catch (IOException exception) {
failureHandler.accept(exception);
sender.sendMessage("Player state could not be saved; no change was applied.");
return false;
}
}
private void notifyTarget(ResolvedPlayer target, String message) {
Player online = target.onlinePlayer();
if (online != null) {
online.sendMessage(message);
}
}
private void sendUsage(CommandSender sender) {
sender.sendMessage("Usage: /treefelleradmin <player|threshold>");
}
private Optional<Boolean> parseBoolean(String value) {
if (value.equalsIgnoreCase("on")) {
return Optional.of(true);
}
if (value.equalsIgnoreCase("off")) {
return Optional.of(false);
}
return Optional.empty();
}
private List<String> treeIdentifiers() {
return Arrays.stream(TreeSpecies.values()).map(TreeSpecies::id).toList();
}
private List<String> matching(List<String> values, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT);
return values.stream()
.filter(value -> value.toLowerCase(Locale.ROOT).startsWith(normalized))
.toList();
}
}
@@ -65,7 +65,10 @@ public final class TreeFellerPlugin extends JavaPlugin {
playerStateRepository,
automaticBreakRegistry,
fellingEngine,
() -> settingsService.current().animationDelayTicks());
() -> settingsService.current().animationDelayTicks(),
player -> player.sendMessage(settingsService.current()
.message("administratively-locked")
.replace('&', '\u00a7')));
getServer().getPluginManager().registerEvents(fellingEngine, this);
getServer().getPluginManager().registerEvents(fellingListener, this);
@@ -88,6 +91,18 @@ public final class TreeFellerPlugin extends JavaPlugin {
getCommand("treefeller"), "treefeller command missing from plugin.yml");
command.setExecutor(playerCommand);
command.setTabCompleter(playerCommand);
TreeFellerAdminCommand adminCommand = new TreeFellerAdminCommand(
getServer(),
playerStateRepository,
settingsService,
exception -> getLogger().log(
Level.SEVERE, "Unable to persist Tree Feller administration", exception));
PluginCommand admin = Objects.requireNonNull(
getCommand("treefelleradmin"),
"treefelleradmin command missing from plugin.yml");
admin.setExecutor(adminCommand);
admin.setTabCompleter(adminCommand);
} catch (IllegalArgumentException exception) {
getLogger().severe("Tree Feller configuration is invalid: " + exception.getMessage());
getServer().getPluginManager().disablePlugin(this);
@@ -1,6 +1,7 @@
package games.dmg.treefeller;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.IntSupplier;
import org.bukkit.GameMode;
import org.bukkit.entity.Player;
@@ -16,6 +17,7 @@ public final class TreeFellingListener implements Listener {
private final AutomaticBreakRegistry automaticBreaks;
private final TreeFellingStarter starter;
private final IntSupplier animationDelay;
private final Consumer<Player> lockedNotifier;
public TreeFellingListener(
TreeDetector detector,
@@ -23,11 +25,22 @@ public final class TreeFellingListener implements Listener {
AutomaticBreakRegistry automaticBreaks,
TreeFellingStarter starter,
IntSupplier animationDelay) {
this(detector, states, automaticBreaks, starter, animationDelay, ignored -> { });
}
public TreeFellingListener(
TreeDetector detector,
PlayerStateStore states,
AutomaticBreakRegistry automaticBreaks,
TreeFellingStarter starter,
IntSupplier animationDelay,
Consumer<Player> lockedNotifier) {
this.detector = detector;
this.states = states;
this.automaticBreaks = automaticBreaks;
this.starter = starter;
this.animationDelay = animationDelay;
this.lockedNotifier = lockedNotifier;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -43,7 +56,11 @@ public final class TreeFellingListener implements Listener {
PlayerTreeFellerState state = states.load(player.getUniqueId())
.orElseGet(() -> PlayerTreeFellerState.initial(
player.getUniqueId(), player.getName()));
if (!state.enabled() || state.locked()) {
if (!state.enabled()) {
return;
}
if (state.locked()) {
lockedNotifier.accept(player);
return;
}
Optional<TreeStructure> detected = detector.detect(event.getBlock());
@@ -19,7 +19,7 @@ import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
/** UUID-keyed YAML persistence that retains fields it does not own. */
public final class YamlPlayerStateRepository implements PlayerStateStore {
public final class YamlPlayerStateRepository implements PlayerStateCatalog {
private final Path file;
private final YamlConfiguration document;
@@ -43,6 +43,7 @@ public final class YamlPlayerStateRepository implements PlayerStateStore {
}
}
@Override
public List<PlayerTreeFellerState> loadAll() {
ConfigurationSection players = document.getConfigurationSection("players");
if (players == null) {