feat(admin): add Tree Feller administration
This commit is contained in:
@@ -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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user