diff --git a/design/log.md b/design/log.md index a3715d6..6063c09 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,15 @@ ## 2026-08-11 +### US-007 administration completed + +- Added the dedicated permission-gated `/treefelleradmin` tree for player status, species grants and resets, global player felling locks, and persistent per-species thresholds. +- Added exact live-name, durable-alias, and UUID target resolution with ambiguity rejection and UUID authority. +- Added positional, permission-aware completion for roots, known players, properties, species identifiers, actions, and boolean values. +- Administrative mutations are idempotent, persist before reporting success, preserve unrelated state, and notify online targets without using earned-achievement titles. +- Administratively locked players continue manual progress, receive a configurable explanation when felling is suppressed, and retain their preference and unlocks. +- Verified grants, resets, locks, thresholds, identity safety, online messaging, autocomplete, authorization, and the complete build with `./gradlew clean check jar`. + ### US-006 unlock announcement checkpoint - Added configurable, placeholder-aware titles, subtitles, timing, and chat guidance for newly earned species. diff --git a/design/user-stories/us-007-administer-tree-feller.md b/design/user-stories/us-007-administer-tree-feller.md index c69e958..9fc3f9e 100644 --- a/design/user-stories/us-007-administer-tree-feller.md +++ b/design/user-stories/us-007-administer-tree-feller.md @@ -2,7 +2,7 @@ type: User Story title: "US-007: Administer Tree Feller" description: Give administrators separate, structured commands for player access, progress, and species thresholds. -status: backlog +status: done --- # US-007: Administer Tree Feller @@ -11,22 +11,22 @@ As a **server administrator**, I want a dedicated administrative command tree so ## Acceptance criteria -- [ ] All administrative operations are rooted at `/treefelleradmin` rather than `/treefeller`. -- [ ] Administrative commands require `treefeller.admin`, which server operators receive by default and ordinary player permissions never imply. -- [ ] `/treefelleradmin player status` reports identity, saved enabled preference, administrative lock, and each species' progress, threshold, and unlock state. -- [ ] `/treefelleradmin player tree grant` grants that species permanently without altering unrelated species. -- [ ] `/treefelleradmin player tree reset` removes that species' unlock and resets its progress to zero without altering unrelated species. -- [ ] A reset player can earn the species again and receive its normal earned-unlock announcement. -- [ ] `/treefelleradmin player locked ` controls an override that prevents all automatic felling for the player without changing their preference, progress, or unlocks. -- [ ] An administratively locked player may continue accruing qualifying manual progress and receives a clear explanation when automatic felling is suppressed. -- [ ] `/treefelleradmin threshold ` validates and persistently changes the named species' unlock threshold. -- [ ] Lowering a threshold does not scan or immediately mutate all player records; each affected player unlocks on their next qualifying block of that species. -- [ ] Raising a threshold never revokes existing unlocks. -- [ ] Player targets resolve exact online names, previously known names, and UUIDs without confusing players who have used the same name. -- [ ] Tree-type arguments use stable documented identifiers covering every supported species. -- [ ] Autocomplete is permission-aware and suggests valid subcommands, known player targets, properties, tree types, actions, and values for the current argument position. -- [ ] Every successful mutation reports exactly what changed to the administrator and, when online, the affected player. -- [ ] Invalid or unauthorized requests make no partial state or configuration changes. +- [x] All administrative operations are rooted at `/treefelleradmin` rather than `/treefeller`. +- [x] Administrative commands require `treefeller.admin`, which server operators receive by default and ordinary player permissions never imply. +- [x] `/treefelleradmin player status` reports identity, saved enabled preference, administrative lock, and each species' progress, threshold, and unlock state. +- [x] `/treefelleradmin player tree grant` grants that species permanently without altering unrelated species. +- [x] `/treefelleradmin player tree reset` removes that species' unlock and resets its progress to zero without altering unrelated species. +- [x] A reset player can earn the species again and receive its normal earned-unlock announcement. +- [x] `/treefelleradmin player locked ` controls an override that prevents all automatic felling for the player without changing their preference, progress, or unlocks. +- [x] An administratively locked player may continue accruing qualifying manual progress and receives a clear explanation when automatic felling is suppressed. +- [x] `/treefelleradmin threshold ` validates and persistently changes the named species' unlock threshold. +- [x] Lowering a threshold does not scan or immediately mutate all player records; each affected player unlocks on their next qualifying block of that species. +- [x] Raising a threshold never revokes existing unlocks. +- [x] Player targets resolve exact online names, previously known names, and UUIDs without confusing players who have used the same name. +- [x] Tree-type arguments use stable documented identifiers covering every supported species. +- [x] Autocomplete is permission-aware and suggests valid subcommands, known player targets, properties, tree types, actions, and values for the current argument position. +- [x] Every successful mutation reports exactly what changed to the administrator and, when online, the affected player. +- [x] Invalid or unauthorized requests make no partial state or configuration changes. ## Related diff --git a/src/main/java/games/dmg/treefeller/PlayerStateCatalog.java b/src/main/java/games/dmg/treefeller/PlayerStateCatalog.java new file mode 100644 index 0000000..bec78f4 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/PlayerStateCatalog.java @@ -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 loadAll(); +} diff --git a/src/main/java/games/dmg/treefeller/PlayerTargetResolver.java b/src/main/java/games/dmg/treefeller/PlayerTargetResolver.java new file mode 100644 index 0000000..b2489cf --- /dev/null +++ b/src/main/java/games/dmg/treefeller/PlayerTargetResolver.java @@ -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 parsedId = parseUuid(value); + if (parsedId.isPresent()) { + UUID playerId = parsedId.orElseThrow(); + Player online = server.getPlayer(playerId); + Optional 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 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 suggestions() { + Set 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 parseUuid(String value) { + try { + return Optional.of(UUID.fromString(value)); + } catch (IllegalArgumentException exception) { + return Optional.empty(); + } + } +} diff --git a/src/main/java/games/dmg/treefeller/ResolvedPlayer.java b/src/main/java/games/dmg/treefeller/ResolvedPlayer.java new file mode 100644 index 0000000..f102f8d --- /dev/null +++ b/src/main/java/games/dmg/treefeller/ResolvedPlayer.java @@ -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) { +} diff --git a/src/main/java/games/dmg/treefeller/TargetResolution.java b/src/main/java/games/dmg/treefeller/TargetResolution.java new file mode 100644 index 0000000..dbfdb33 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/TargetResolution.java @@ -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); + } +} diff --git a/src/main/java/games/dmg/treefeller/TargetResolutionStatus.java b/src/main/java/games/dmg/treefeller/TargetResolutionStatus.java new file mode 100644 index 0000000..fa1ce17 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/TargetResolutionStatus.java @@ -0,0 +1,8 @@ +package games.dmg.treefeller; + +/** Player target lookup result. */ +public enum TargetResolutionStatus { + FOUND, + NOT_FOUND, + AMBIGUOUS +} diff --git a/src/main/java/games/dmg/treefeller/TreeFellerAdminCommand.java b/src/main/java/games/dmg/treefeller/TreeFellerAdminCommand.java new file mode 100644 index 0000000..6147dec --- /dev/null +++ b/src/main/java/games/dmg/treefeller/TreeFellerAdminCommand.java @@ -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 ROOTS = List.of("player", "threshold"); + private static final List PLAYER_PROPERTIES = List.of("locked", "status", "tree"); + private static final List BOOLEAN_VALUES = List.of("off", "on"); + private static final List TREE_ACTIONS = List.of("grant", "reset"); + + private final PlayerStateCatalog states; + private final TreeFellerSettingsService settings; + private final Consumer failureHandler; + private final PlayerTargetResolver targets; + + public TreeFellerAdminCommand( + Server server, + PlayerStateCatalog states, + TreeFellerSettingsService settings, + Consumer 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 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 "); + return; + } + Optional 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 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 locked "); + return; + } + Optional 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 tree "); + return; + } + Optional 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 "); + } + + private Optional parseBoolean(String value) { + if (value.equalsIgnoreCase("on")) { + return Optional.of(true); + } + if (value.equalsIgnoreCase("off")) { + return Optional.of(false); + } + return Optional.empty(); + } + + private List treeIdentifiers() { + return Arrays.stream(TreeSpecies.values()).map(TreeSpecies::id).toList(); + } + + private List matching(List values, String prefix) { + String normalized = prefix.toLowerCase(Locale.ROOT); + return values.stream() + .filter(value -> value.toLowerCase(Locale.ROOT).startsWith(normalized)) + .toList(); + } +} diff --git a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java index 7e40204..244e4b5 100644 --- a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java +++ b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java @@ -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); diff --git a/src/main/java/games/dmg/treefeller/TreeFellingListener.java b/src/main/java/games/dmg/treefeller/TreeFellingListener.java index 23e7d58..2874aa6 100644 --- a/src/main/java/games/dmg/treefeller/TreeFellingListener.java +++ b/src/main/java/games/dmg/treefeller/TreeFellingListener.java @@ -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 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 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 detected = detector.detect(event.getBlock()); diff --git a/src/main/java/games/dmg/treefeller/YamlPlayerStateRepository.java b/src/main/java/games/dmg/treefeller/YamlPlayerStateRepository.java index 482116e..de46239 100644 --- a/src/main/java/games/dmg/treefeller/YamlPlayerStateRepository.java +++ b/src/main/java/games/dmg/treefeller/YamlPlayerStateRepository.java @@ -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 loadAll() { ConfigurationSection players = document.getConfigurationSection("players"); if (players == null) { diff --git a/src/test/java/games/dmg/treefeller/PlayerTargetResolverTest.java b/src/test/java/games/dmg/treefeller/PlayerTargetResolverTest.java new file mode 100644 index 0000000..887c49f --- /dev/null +++ b/src/test/java/games/dmg/treefeller/PlayerTargetResolverTest.java @@ -0,0 +1,51 @@ +package games.dmg.treefeller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class PlayerTargetResolverTest { + @Test + void rejectsAnAmbiguousHistoricalNameButAcceptsAnAuthoritativeUuid() { + UUID firstId = UUID.randomUUID(); + UUID secondId = UUID.randomUUID(); + PlayerTreeFellerState first = PlayerTreeFellerState.initial(firstId, "First") + .observeName("Shared"); + PlayerTreeFellerState second = PlayerTreeFellerState.initial(secondId, "Second") + .observeName("Shared"); + PlayerStateCatalog states = new Catalog(List.of(first, second)); + Server server = mock(Server.class); + when(server.getOnlinePlayers()).thenReturn(List.of()); + PlayerTargetResolver resolver = new PlayerTargetResolver(server, states); + + assertEquals(TargetResolutionStatus.AMBIGUOUS, resolver.resolve("Shared").status()); + TargetResolution byUuid = resolver.resolve(firstId.toString()); + assertEquals(TargetResolutionStatus.FOUND, byUuid.status()); + assertEquals(firstId, byUuid.target().playerId()); + } + + private record Catalog(List values) implements PlayerStateCatalog { + @Override + public Optional load(UUID playerId) { + return values.stream().filter(state -> state.playerId().equals(playerId)).findFirst(); + } + + @Override + public List loadAll() { + return values; + } + + @Override + public void save(PlayerTreeFellerState state) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/test/java/games/dmg/treefeller/TreeFellerAdminCommandTest.java b/src/test/java/games/dmg/treefeller/TreeFellerAdminCommandTest.java new file mode 100644 index 0000000..39474ae --- /dev/null +++ b/src/test/java/games/dmg/treefeller/TreeFellerAdminCommandTest.java @@ -0,0 +1,173 @@ +package games.dmg.treefeller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.startsWith; +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.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.Server; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class TreeFellerAdminCommandTest { + @Test + void grantsAndResetsOnlyTheNamedSpeciesWithDistinctOnlineMessages() throws Exception { + UUID playerId = UUID.randomUUID(); + Player target = mock(Player.class); + when(target.getUniqueId()).thenReturn(playerId); + when(target.getName()).thenReturn("Target"); + Server server = serverWith(target); + InMemoryCatalog states = new InMemoryCatalog(); + states.state = PlayerTreeFellerState.initial(playerId, "Target") + .withProgress(TreeSpecies.OAK, 40) + .withUnlocked(TreeSpecies.BIRCH, true); + TreeFellerAdminCommand handler = handler(server, states); + CommandSender administrator = administrator(); + + handler.onCommand(administrator, mock(Command.class), "treefelleradmin", + new String[] {"player", "Target", "tree", "oak", "grant"}); + + assertTrue(states.state.isUnlocked(TreeSpecies.OAK)); + assertTrue(states.state.isUnlocked(TreeSpecies.BIRCH)); + assertEquals(40, states.state.progress(TreeSpecies.OAK)); + verify(target).sendMessage(contains("granted")); + verify(target, never()).sendTitle( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.anyInt()); + + handler.onCommand(administrator, mock(Command.class), "treefelleradmin", + new String[] {"player", "Target", "tree", "oak", "reset"}); + + assertFalse(states.state.isUnlocked(TreeSpecies.OAK)); + assertEquals(0, states.state.progress(TreeSpecies.OAK)); + assertTrue(states.state.isUnlocked(TreeSpecies.BIRCH)); + } + + @Test + void lockSuppressesAllAutomaticFellingWithoutErasingPreferenceOrProgress() throws Exception { + UUID playerId = UUID.randomUUID(); + Player target = mock(Player.class); + when(target.getUniqueId()).thenReturn(playerId); + when(target.getName()).thenReturn("Target"); + InMemoryCatalog states = new InMemoryCatalog(); + states.state = PlayerTreeFellerState.initial(playerId, "Target") + .withProgress(TreeSpecies.OAK, 12); + TreeFellerAdminCommand handler = handler(serverWith(target), states); + + handler.onCommand(administrator(), mock(Command.class), "treefelleradmin", + new String[] {"player", "Target", "locked", "on"}); + + assertTrue(states.state.locked()); + assertTrue(states.state.enabled()); + assertEquals(12, states.state.progress(TreeSpecies.OAK)); + verify(target).sendMessage(contains("locked")); + } + + @Test + void changesAndPersistsAValidatedSpeciesThreshold() throws Exception { + InMemoryCatalog states = new InMemoryCatalog(); + TreeFellerSettingsService settings = new TreeFellerSettingsService( + defaults(), (species, threshold) -> { }); + TreeFellerAdminCommand handler = new TreeFellerAdminCommand( + mock(Server.class), states, settings, ignored -> { }); + CommandSender administrator = administrator(); + + handler.onCommand(administrator, mock(Command.class), "treefelleradmin", + new String[] {"threshold", "dark-oak", "25"}); + + assertEquals(25, settings.current().threshold(TreeSpecies.DARK_OAK)); + verify(administrator).sendMessage(contains("Dark Oak")); + verify(administrator).sendMessage(contains("25")); + } + + @Test + void autocompleteIsPermissionAwareAndPositionSpecific() throws Exception { + Player target = mock(Player.class); + when(target.getUniqueId()).thenReturn(UUID.randomUUID()); + when(target.getName()).thenReturn("Target"); + TreeFellerAdminCommand handler = handler(serverWith(target), new InMemoryCatalog()); + CommandSender denied = mock(CommandSender.class); + CommandSender administrator = administrator(); + + assertEquals(List.of(), handler.onTabComplete( + denied, mock(Command.class), "treefelleradmin", new String[] {""})); + assertEquals(List.of("player"), handler.onTabComplete( + administrator, mock(Command.class), "treefelleradmin", new String[] {"p"})); + assertEquals(List.of("Target"), handler.onTabComplete( + administrator, mock(Command.class), "treefelleradmin", new String[] {"player", "T"})); + assertEquals(List.of("grant"), handler.onTabComplete( + administrator, + mock(Command.class), + "treefelleradmin", + new String[] {"player", "Target", "tree", "oak", "g"})); + } + + private TreeFellerAdminCommand handler(Server server, InMemoryCatalog states) throws Exception { + return new TreeFellerAdminCommand( + server, + states, + new TreeFellerSettingsService(defaults(), (species, threshold) -> { }), + ignored -> { }); + } + + private Server serverWith(Player player) { + Server server = mock(Server.class); + Collection players = List.of(player); + when(server.getOnlinePlayers()).thenAnswer(ignored -> players); + when(server.getPlayer(player.getUniqueId())).thenReturn(player); + return server; + } + + private CommandSender administrator() { + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("treefeller.admin")).thenReturn(true); + return sender; + } + + private TreeFellerSettings defaults() throws Exception { + try (InputStreamReader reader = new InputStreamReader( + getClass().getClassLoader().getResourceAsStream("config.yml"), + StandardCharsets.UTF_8)) { + return TreeFellerSettings.load(YamlConfiguration.loadConfiguration(reader)); + } + } + + private static final class InMemoryCatalog implements PlayerStateCatalog { + private PlayerTreeFellerState state; + + @Override + public Optional load(UUID playerId) { + return state != null && state.playerId().equals(playerId) + ? Optional.of(state) + : Optional.empty(); + } + + @Override + public List loadAll() { + return state == null ? List.of() : new ArrayList<>(List.of(state)); + } + + @Override + public void save(PlayerTreeFellerState changed) { + state = changed; + } + } +} diff --git a/src/test/java/games/dmg/treefeller/TreeFellingListenerTest.java b/src/test/java/games/dmg/treefeller/TreeFellingListenerTest.java index 7eb8d21..ab912d4 100644 --- a/src/test/java/games/dmg/treefeller/TreeFellingListenerTest.java +++ b/src/test/java/games/dmg/treefeller/TreeFellingListenerTest.java @@ -1,6 +1,7 @@ package games.dmg.treefeller; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -75,7 +76,8 @@ class TreeFellingListenerTest { states, new AutomaticBreakRegistry(), starter, - () -> 2); + () -> 2, + target -> target.sendMessage("administratively locked")); Block block = mock(Block.class); World world = mock(World.class); when(block.getWorld()).thenReturn(world); @@ -84,5 +86,6 @@ class TreeFellingListenerTest { listener.onBlockBreak(new BlockBreakEvent(block, player)); verify(starter, never()).start(any(), any(), any(), any(Integer.class)); + verify(player).sendMessage(contains("administratively locked")); } }