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
+9
View File
@@ -2,6 +2,15 @@
## 2026-08-11 ## 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 ### US-006 unlock announcement checkpoint
- Added configurable, placeholder-aware titles, subtitles, timing, and chat guidance for newly earned species. - Added configurable, placeholder-aware titles, subtitles, timing, and chat guidance for newly earned species.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-007: Administer Tree Feller" title: "US-007: Administer Tree Feller"
description: Give administrators separate, structured commands for player access, progress, and species thresholds. description: Give administrators separate, structured commands for player access, progress, and species thresholds.
status: backlog status: done
--- ---
# US-007: Administer Tree Feller # US-007: Administer Tree Feller
@@ -11,22 +11,22 @@ As a **server administrator**, I want a dedicated administrative command tree so
## Acceptance criteria ## Acceptance criteria
- [ ] All administrative operations are rooted at `/treefelleradmin` rather than `/treefeller`. - [x] 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. - [x] Administrative commands require `treefeller.admin`, which server operators receive by default and ordinary player permissions never imply.
- [ ] `/treefelleradmin player <name|uuid> status` reports identity, saved enabled preference, administrative lock, and each species' progress, threshold, and unlock state. - [x] `/treefelleradmin player <name|uuid> status` reports identity, saved enabled preference, administrative lock, and each species' progress, threshold, and unlock state.
- [ ] `/treefelleradmin player <name|uuid> tree <type> grant` grants that species permanently without altering unrelated species. - [x] `/treefelleradmin player <name|uuid> tree <type> grant` grants that species permanently without altering unrelated species.
- [ ] `/treefelleradmin player <name|uuid> tree <type> reset` removes that species' unlock and resets its progress to zero without altering unrelated species. - [x] `/treefelleradmin player <name|uuid> tree <type> 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. - [x] A reset player can earn the species again and receive its normal earned-unlock announcement.
- [ ] `/treefelleradmin player <name|uuid> locked <on|off>` controls an override that prevents all automatic felling for the player without changing their preference, progress, or unlocks. - [x] `/treefelleradmin player <name|uuid> locked <on|off>` 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. - [x] An administratively locked player may continue accruing qualifying manual progress and receives a clear explanation when automatic felling is suppressed.
- [ ] `/treefelleradmin threshold <type> <blocks>` validates and persistently changes the named species' unlock threshold. - [x] `/treefelleradmin threshold <type> <blocks>` 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. - [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.
- [ ] Raising a threshold never revokes existing unlocks. - [x] 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. - [x] 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. - [x] 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. - [x] 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. - [x] 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] Invalid or unauthorized requests make no partial state or configuration changes.
## Related ## Related
@@ -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, playerStateRepository,
automaticBreakRegistry, automaticBreakRegistry,
fellingEngine, 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(fellingEngine, this);
getServer().getPluginManager().registerEvents(fellingListener, this); getServer().getPluginManager().registerEvents(fellingListener, this);
@@ -88,6 +91,18 @@ public final class TreeFellerPlugin extends JavaPlugin {
getCommand("treefeller"), "treefeller command missing from plugin.yml"); getCommand("treefeller"), "treefeller command missing from plugin.yml");
command.setExecutor(playerCommand); command.setExecutor(playerCommand);
command.setTabCompleter(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) { } catch (IllegalArgumentException exception) {
getLogger().severe("Tree Feller configuration is invalid: " + exception.getMessage()); getLogger().severe("Tree Feller configuration is invalid: " + exception.getMessage());
getServer().getPluginManager().disablePlugin(this); getServer().getPluginManager().disablePlugin(this);
@@ -1,6 +1,7 @@
package games.dmg.treefeller; package games.dmg.treefeller;
import java.util.Optional; import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.IntSupplier; import java.util.function.IntSupplier;
import org.bukkit.GameMode; import org.bukkit.GameMode;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@@ -16,6 +17,7 @@ public final class TreeFellingListener implements Listener {
private final AutomaticBreakRegistry automaticBreaks; private final AutomaticBreakRegistry automaticBreaks;
private final TreeFellingStarter starter; private final TreeFellingStarter starter;
private final IntSupplier animationDelay; private final IntSupplier animationDelay;
private final Consumer<Player> lockedNotifier;
public TreeFellingListener( public TreeFellingListener(
TreeDetector detector, TreeDetector detector,
@@ -23,11 +25,22 @@ public final class TreeFellingListener implements Listener {
AutomaticBreakRegistry automaticBreaks, AutomaticBreakRegistry automaticBreaks,
TreeFellingStarter starter, TreeFellingStarter starter,
IntSupplier animationDelay) { 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.detector = detector;
this.states = states; this.states = states;
this.automaticBreaks = automaticBreaks; this.automaticBreaks = automaticBreaks;
this.starter = starter; this.starter = starter;
this.animationDelay = animationDelay; this.animationDelay = animationDelay;
this.lockedNotifier = lockedNotifier;
} }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@@ -43,7 +56,11 @@ public final class TreeFellingListener implements Listener {
PlayerTreeFellerState state = states.load(player.getUniqueId()) PlayerTreeFellerState state = states.load(player.getUniqueId())
.orElseGet(() -> PlayerTreeFellerState.initial( .orElseGet(() -> PlayerTreeFellerState.initial(
player.getUniqueId(), player.getName())); player.getUniqueId(), player.getName()));
if (!state.enabled() || state.locked()) { if (!state.enabled()) {
return;
}
if (state.locked()) {
lockedNotifier.accept(player);
return; return;
} }
Optional<TreeStructure> detected = detector.detect(event.getBlock()); Optional<TreeStructure> detected = detector.detect(event.getBlock());
@@ -19,7 +19,7 @@ import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
/** UUID-keyed YAML persistence that retains fields it does not own. */ /** 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 Path file;
private final YamlConfiguration document; private final YamlConfiguration document;
@@ -43,6 +43,7 @@ public final class YamlPlayerStateRepository implements PlayerStateStore {
} }
} }
@Override
public List<PlayerTreeFellerState> loadAll() { public List<PlayerTreeFellerState> loadAll() {
ConfigurationSection players = document.getConfigurationSection("players"); ConfigurationSection players = document.getConfigurationSection("players");
if (players == null) { if (players == null) {
@@ -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<PlayerTreeFellerState> values) implements PlayerStateCatalog {
@Override
public Optional<PlayerTreeFellerState> load(UUID playerId) {
return values.stream().filter(state -> state.playerId().equals(playerId)).findFirst();
}
@Override
public List<PlayerTreeFellerState> loadAll() {
return values;
}
@Override
public void save(PlayerTreeFellerState state) {
throw new UnsupportedOperationException();
}
}
}
@@ -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<Player> 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<PlayerTreeFellerState> load(UUID playerId) {
return state != null && state.playerId().equals(playerId)
? Optional.of(state)
: Optional.empty();
}
@Override
public List<PlayerTreeFellerState> loadAll() {
return state == null ? List.of() : new ArrayList<>(List.of(state));
}
@Override
public void save(PlayerTreeFellerState changed) {
state = changed;
}
}
}
@@ -1,6 +1,7 @@
package games.dmg.treefeller; package games.dmg.treefeller;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -75,7 +76,8 @@ class TreeFellingListenerTest {
states, states,
new AutomaticBreakRegistry(), new AutomaticBreakRegistry(),
starter, starter,
() -> 2); () -> 2,
target -> target.sendMessage("administratively locked"));
Block block = mock(Block.class); Block block = mock(Block.class);
World world = mock(World.class); World world = mock(World.class);
when(block.getWorld()).thenReturn(world); when(block.getWorld()).thenReturn(world);
@@ -84,5 +86,6 @@ class TreeFellingListenerTest {
listener.onBlockBreak(new BlockBreakEvent(block, player)); listener.onBlockBreak(new BlockBreakEvent(block, player));
verify(starter, never()).start(any(), any(), any(), any(Integer.class)); verify(starter, never()).start(any(), any(), any(), any(Integer.class));
verify(player).sendMessage(contains("administratively locked"));
} }
} }