feat(admin): add Leaf administration commands
This commit is contained in:
@@ -4,6 +4,7 @@ import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabExecutor;
|
||||
@@ -12,6 +13,10 @@ import org.bukkit.entity.Player;
|
||||
/** Implements the permission-aware /leaf command tree. */
|
||||
public final class LeafCommand implements TabExecutor {
|
||||
private static final List<String> PLAYER_COMMANDS = List.of("on", "off", "status");
|
||||
private static final List<String> ADMIN_COMMANDS = List.of("enabled", "strength", "player");
|
||||
private static final List<String> ON_OFF = List.of("on", "off");
|
||||
private static final List<String> LEVELS = List.of("1", "2", "3", "4", "5");
|
||||
private static final List<String> PLAYER_PROPERTIES = List.of("status", "enabled", "locked");
|
||||
private final LeafRuntime runtime;
|
||||
|
||||
public LeafCommand(LeafRuntime runtime) {
|
||||
@@ -25,29 +30,21 @@ public final class LeafCommand implements TabExecutor {
|
||||
String label,
|
||||
String[] arguments
|
||||
) {
|
||||
if (arguments.length != 1 || !PLAYER_COMMANDS.contains(arguments[0].toLowerCase(Locale.ROOT))) {
|
||||
sender.sendMessage("Usage: /leaf <on|off|status>");
|
||||
if (arguments.length == 0) {
|
||||
sendUsage(sender);
|
||||
return true;
|
||||
}
|
||||
if (!(sender instanceof Player player)) {
|
||||
sender.sendMessage("This Leaf command must be run by a player.");
|
||||
return true;
|
||||
}
|
||||
if (!sender.hasPermission("leaf.use")) {
|
||||
sender.sendMessage("You do not have permission to use Leaf.");
|
||||
return true;
|
||||
}
|
||||
|
||||
String action = arguments[0].toLowerCase(Locale.ROOT);
|
||||
try {
|
||||
switch (action) {
|
||||
case "on" -> reportChoice(player, true, runtime.setOwnChoice(player, true));
|
||||
case "off" -> reportChoice(player, false, runtime.setOwnChoice(player, false));
|
||||
case "status" -> reportStatus(player);
|
||||
default -> throw new IllegalStateException("validated action was not handled");
|
||||
if (PLAYER_COMMANDS.contains(action)) {
|
||||
executePlayer(sender, action, arguments);
|
||||
} else if (ADMIN_COMMANDS.contains(action)) {
|
||||
executeAdmin(sender, action, arguments);
|
||||
} else {
|
||||
sendUsage(sender);
|
||||
}
|
||||
} catch (IOException | IllegalArgumentException exception) {
|
||||
sender.sendMessage("Leaf could not save your request; no partial change was applied.");
|
||||
sender.sendMessage("Leaf request failed: " + exception.getMessage() + ". No partial change was applied.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -59,32 +56,173 @@ public final class LeafCommand implements TabExecutor {
|
||||
String alias,
|
||||
String[] arguments
|
||||
) {
|
||||
if (arguments.length != 1 || !sender.hasPermission("leaf.use")) {
|
||||
if (arguments.length == 1) {
|
||||
ArrayList<String> roots = new ArrayList<>();
|
||||
if (sender.hasPermission("leaf.use")) {
|
||||
roots.addAll(PLAYER_COMMANDS);
|
||||
}
|
||||
if (sender.hasPermission("leaf.admin")) {
|
||||
roots.addAll(ADMIN_COMMANDS);
|
||||
}
|
||||
return matching(roots, arguments[0]);
|
||||
}
|
||||
if (!sender.hasPermission("leaf.admin")) {
|
||||
return List.of();
|
||||
}
|
||||
return matching(PLAYER_COMMANDS, arguments[0]);
|
||||
String root = arguments[0].toLowerCase(Locale.ROOT);
|
||||
if (arguments.length == 2) {
|
||||
return switch (root) {
|
||||
case "enabled" -> matching(ON_OFF, arguments[1]);
|
||||
case "strength" -> matching(LEVELS, arguments[1]);
|
||||
case "player" -> matching(runtime.knownTargets(), arguments[1]);
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
if (arguments.length == 3 && root.equals("player")) {
|
||||
return matching(PLAYER_PROPERTIES, arguments[2]);
|
||||
}
|
||||
if (arguments.length == 4 && root.equals("player")
|
||||
&& (arguments[2].equalsIgnoreCase("enabled")
|
||||
|| arguments[2].equalsIgnoreCase("locked"))) {
|
||||
return matching(ON_OFF, arguments[3]);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private void reportChoice(Player player, boolean enabled, LeafRuntime.Change change) {
|
||||
if (change == LeafRuntime.Change.LOCKED) {
|
||||
player.sendMessage(LeafText.color(runtime.settings().lockedMessage()));
|
||||
} else if (change == LeafRuntime.Change.UNCHANGED) {
|
||||
player.sendMessage("Leaf was already " + (enabled ? "enabled" : "disabled") + ".");
|
||||
} else {
|
||||
player.sendMessage("Leaf protection is now " + (enabled ? "enabled" : "disabled") + ".");
|
||||
private void executePlayer(CommandSender sender, String action, String[] arguments)
|
||||
throws IOException {
|
||||
if (arguments.length != 1) {
|
||||
throw new IllegalArgumentException("Usage: /leaf <on|off|status>");
|
||||
}
|
||||
if (!(sender instanceof Player player)) {
|
||||
throw new IllegalArgumentException("this command must be run by a player");
|
||||
}
|
||||
if (!sender.hasPermission("leaf.use")) {
|
||||
throw new IllegalArgumentException("you do not have permission to use Leaf");
|
||||
}
|
||||
switch (action) {
|
||||
case "on" -> reportOwnChoice(player, true, runtime.setOwnChoice(player, true));
|
||||
case "off" -> reportOwnChoice(player, false, runtime.setOwnChoice(player, false));
|
||||
case "status" -> reportStatus(player, player.getUniqueId(), false);
|
||||
default -> throw new IllegalStateException("validated action was not handled");
|
||||
}
|
||||
}
|
||||
|
||||
private void reportStatus(Player player) {
|
||||
LeafRuntime.Status status = runtime.status(player.getUniqueId());
|
||||
player.sendMessage(
|
||||
"Leaf status — Saved choice: " + onOff(status.savedChoice())
|
||||
private void executeAdmin(CommandSender sender, String action, String[] arguments)
|
||||
throws IOException {
|
||||
if (!sender.hasPermission("leaf.admin")) {
|
||||
throw new IllegalArgumentException("you do not have permission to administer Leaf");
|
||||
}
|
||||
switch (action) {
|
||||
case "enabled" -> executeGlobalEnabled(sender, arguments);
|
||||
case "strength" -> executeStrength(sender, arguments);
|
||||
case "player" -> executeTargeted(sender, arguments);
|
||||
default -> throw new IllegalStateException("validated action was not handled");
|
||||
}
|
||||
}
|
||||
|
||||
private void executeGlobalEnabled(CommandSender sender, String[] arguments) throws IOException {
|
||||
if (arguments.length != 2) {
|
||||
throw new IllegalArgumentException("Usage: /leaf enabled <on|off>");
|
||||
}
|
||||
boolean enabled = parseOnOff(arguments[1]);
|
||||
LeafRuntime.Change change = runtime.setGlobalEnabled(enabled);
|
||||
sender.sendMessage(changeMessage("Global Leaf", enabled, change));
|
||||
}
|
||||
|
||||
private void executeStrength(CommandSender sender, String[] arguments) throws IOException {
|
||||
if (arguments.length != 2) {
|
||||
throw new IllegalArgumentException("Usage: /leaf strength <1-5>");
|
||||
}
|
||||
int level;
|
||||
try {
|
||||
level = Integer.parseInt(arguments[1]);
|
||||
} catch (NumberFormatException exception) {
|
||||
throw new IllegalArgumentException("strength must be an integer from 1 through 5", exception);
|
||||
}
|
||||
LeafRuntime.Change change = runtime.setResistanceLevel(level);
|
||||
sender.sendMessage(
|
||||
change == LeafRuntime.Change.CHANGED
|
||||
? "Leaf Resistance strength changed to " + level + "."
|
||||
: "Leaf Resistance strength was already " + level + "."
|
||||
);
|
||||
}
|
||||
|
||||
private void executeTargeted(CommandSender sender, String[] arguments) throws IOException {
|
||||
if (arguments.length < 3 || arguments.length > 4) {
|
||||
throw new IllegalArgumentException(
|
||||
"Usage: /leaf player <name|uuid> <status|enabled|locked> [on|off]"
|
||||
);
|
||||
}
|
||||
UUID playerId = runtime.resolveTarget(arguments[1]);
|
||||
String property = arguments[2].toLowerCase(Locale.ROOT);
|
||||
if (property.equals("status") && arguments.length == 3) {
|
||||
reportStatus(sender, playerId, true);
|
||||
return;
|
||||
}
|
||||
if (arguments.length != 4 || (!property.equals("enabled") && !property.equals("locked"))) {
|
||||
throw new IllegalArgumentException(
|
||||
"Usage: /leaf player <name|uuid> <status|enabled|locked> [on|off]"
|
||||
);
|
||||
}
|
||||
boolean enabled = parseOnOff(arguments[3]);
|
||||
LeafRuntime.Change change = property.equals("enabled")
|
||||
? runtime.setChoice(playerId, enabled)
|
||||
: runtime.setLocked(playerId, enabled);
|
||||
PlayerLeafState state = runtime.playerState(playerId);
|
||||
String subject = state.latestName() + " (" + playerId + ") " + property;
|
||||
sender.sendMessage(changeMessage(subject, enabled, change));
|
||||
}
|
||||
|
||||
private void reportOwnChoice(Player player, boolean enabled, LeafRuntime.Change change) {
|
||||
if (change == LeafRuntime.Change.LOCKED) {
|
||||
player.sendMessage(LeafText.color(runtime.settings().lockedMessage()));
|
||||
} else {
|
||||
player.sendMessage(changeMessage("Leaf protection", enabled, change));
|
||||
}
|
||||
}
|
||||
|
||||
private void reportStatus(CommandSender sender, UUID playerId, boolean includeIdentity) {
|
||||
LeafRuntime.Status status = runtime.status(playerId);
|
||||
String identity = "";
|
||||
if (includeIdentity) {
|
||||
PlayerLeafState state = runtime.playerState(playerId);
|
||||
identity = state.latestName() + " (" + playerId + ") — ";
|
||||
}
|
||||
sender.sendMessage(
|
||||
identity + "Leaf status — Saved choice: " + onOff(status.savedChoice())
|
||||
+ "; active: " + yesNo(status.activeProtection())
|
||||
+ "; locked: " + yesNo(status.locked())
|
||||
+ "; global: " + onOff(status.globallyEnabled()) + "."
|
||||
+ "; global: " + onOff(status.globallyEnabled())
|
||||
+ "; first join: " + status.firstJoin() + "."
|
||||
);
|
||||
}
|
||||
|
||||
private static String changeMessage(
|
||||
String subject,
|
||||
boolean enabled,
|
||||
LeafRuntime.Change change
|
||||
) {
|
||||
String value = onOff(enabled);
|
||||
return change == LeafRuntime.Change.CHANGED
|
||||
? subject + " changed to " + value + "."
|
||||
: subject + " was already " + value + ".";
|
||||
}
|
||||
|
||||
private static boolean parseOnOff(String value) {
|
||||
if (value.equalsIgnoreCase("on")) {
|
||||
return true;
|
||||
}
|
||||
if (value.equalsIgnoreCase("off")) {
|
||||
return false;
|
||||
}
|
||||
throw new IllegalArgumentException("value must be on or off");
|
||||
}
|
||||
|
||||
private static void sendUsage(CommandSender sender) {
|
||||
sender.sendMessage("Usage: /leaf <on|off|status|enabled|strength|player>");
|
||||
}
|
||||
|
||||
static List<String> matching(List<String> candidates, String partial) {
|
||||
String normalized = partial.toLowerCase(Locale.ROOT);
|
||||
List<String> matches = new ArrayList<>();
|
||||
|
||||
@@ -27,7 +27,8 @@ public final class LeafPlugin extends JavaPlugin {
|
||||
settingsProvider,
|
||||
stateManager,
|
||||
new LeafProtection(),
|
||||
new LeafIdentity(getServer(), getLogger())
|
||||
new LeafIdentity(getServer(), getLogger()),
|
||||
this::persistRuntimeSettings
|
||||
);
|
||||
registerRuntime();
|
||||
} catch (IllegalArgumentException | IOException exception) {
|
||||
@@ -75,8 +76,7 @@ public final class LeafPlugin extends JavaPlugin {
|
||||
return stateManager;
|
||||
}
|
||||
|
||||
void persistRuntimeSettings() {
|
||||
LeafSettings settings = settingsProvider.current();
|
||||
void persistRuntimeSettings(LeafSettings settings) {
|
||||
getConfig().set("enabled", settings.enabled());
|
||||
getConfig().set("resistance-level", settings.resistanceLevel());
|
||||
saveConfig();
|
||||
|
||||
@@ -37,6 +37,12 @@ public final class LeafProtection {
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized boolean isEffective(Player player, int level) {
|
||||
return effectForLevel(level).equals(
|
||||
player.getPotionEffect(PotionEffectType.RESISTANCE)
|
||||
);
|
||||
}
|
||||
|
||||
public synchronized boolean owns(Player player) {
|
||||
PotionEffect expected = appliedEffects.get(player.getUniqueId());
|
||||
return expected != null
|
||||
|
||||
@@ -3,6 +3,9 @@ package games.dmg.leaf;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Server;
|
||||
@@ -10,6 +13,11 @@ import org.bukkit.entity.Player;
|
||||
|
||||
/** Coordinates durable player intent with live Leaf-managed protection. */
|
||||
public final class LeafRuntime {
|
||||
@FunctionalInterface
|
||||
public interface SettingsPersistence {
|
||||
void save(LeafSettings settings) throws IOException;
|
||||
}
|
||||
|
||||
public enum Change {
|
||||
CHANGED,
|
||||
UNCHANGED,
|
||||
@@ -29,6 +37,7 @@ public final class LeafRuntime {
|
||||
private final LeafStateManager stateManager;
|
||||
private final LeafProtection protection;
|
||||
private final LeafIdentity identity;
|
||||
private final SettingsPersistence settingsPersistence;
|
||||
|
||||
public LeafRuntime(
|
||||
Server server,
|
||||
@@ -36,12 +45,27 @@ public final class LeafRuntime {
|
||||
LeafStateManager stateManager,
|
||||
LeafProtection protection,
|
||||
LeafIdentity identity
|
||||
) {
|
||||
this(server, settingsProvider, stateManager, protection, identity, settings -> { });
|
||||
}
|
||||
|
||||
public LeafRuntime(
|
||||
Server server,
|
||||
LeafSettingsProvider settingsProvider,
|
||||
LeafStateManager stateManager,
|
||||
LeafProtection protection,
|
||||
LeafIdentity identity,
|
||||
SettingsPersistence settingsPersistence
|
||||
) {
|
||||
this.server = Objects.requireNonNull(server, "server");
|
||||
this.settingsProvider = Objects.requireNonNull(settingsProvider, "settingsProvider");
|
||||
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
|
||||
this.protection = Objects.requireNonNull(protection, "protection");
|
||||
this.identity = Objects.requireNonNull(identity, "identity");
|
||||
this.settingsPersistence = Objects.requireNonNull(
|
||||
settingsPersistence,
|
||||
"settingsPersistence"
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerLeafState observe(Player player, Instant observedAt) throws IOException {
|
||||
@@ -95,6 +119,30 @@ public final class LeafRuntime {
|
||||
return changed;
|
||||
}
|
||||
|
||||
public Change setGlobalEnabled(boolean enabled) throws IOException {
|
||||
LeafSettings current = settingsProvider.current();
|
||||
if (current.enabled() == enabled) {
|
||||
return Change.UNCHANGED;
|
||||
}
|
||||
LeafSettings replacement = current.withEnabled(enabled);
|
||||
settingsPersistence.save(replacement);
|
||||
settingsProvider.replace(replacement);
|
||||
reconcileAllOnline();
|
||||
return Change.CHANGED;
|
||||
}
|
||||
|
||||
public Change setResistanceLevel(int level) throws IOException {
|
||||
LeafSettings current = settingsProvider.current();
|
||||
LeafSettings replacement = current.withResistanceLevel(level);
|
||||
if (current.resistanceLevel() == level) {
|
||||
return Change.UNCHANGED;
|
||||
}
|
||||
settingsPersistence.save(replacement);
|
||||
settingsProvider.replace(replacement);
|
||||
reconcileAllOnline();
|
||||
return Change.CHANGED;
|
||||
}
|
||||
|
||||
public Change setLocked(UUID playerId, boolean locked) throws IOException {
|
||||
PlayerLeafState state = requiredState(playerId);
|
||||
if (state.locked() == locked) {
|
||||
@@ -112,7 +160,7 @@ public final class LeafRuntime {
|
||||
boolean active = online != null
|
||||
&& globallyEnabled
|
||||
&& state.optedIn()
|
||||
&& protection.owns(online);
|
||||
&& protection.isEffective(online, settingsProvider.current().resistanceLevel());
|
||||
return new Status(
|
||||
state.optedIn(),
|
||||
active,
|
||||
@@ -122,12 +170,60 @@ public final class LeafRuntime {
|
||||
);
|
||||
}
|
||||
|
||||
public PlayerLeafState playerState(UUID playerId) {
|
||||
return requiredState(playerId);
|
||||
}
|
||||
|
||||
public UUID resolveTarget(String target) {
|
||||
try {
|
||||
UUID playerId = UUID.fromString(target);
|
||||
return stateManager.find(playerId).map(PlayerLeafState::playerId).orElseThrow(
|
||||
() -> new IllegalArgumentException("Unknown Leaf player: " + target)
|
||||
);
|
||||
} catch (IllegalArgumentException invalidUuidOrUnknown) {
|
||||
Player online = server.getPlayerExact(target);
|
||||
if (online != null && stateManager.find(online.getUniqueId()).isPresent()) {
|
||||
return online.getUniqueId();
|
||||
}
|
||||
List<PlayerLeafState> latestMatches = matchingLatestNames(target);
|
||||
if (latestMatches.size() == 1) {
|
||||
return latestMatches.get(0).playerId();
|
||||
}
|
||||
if (latestMatches.size() > 1) {
|
||||
throw new IllegalArgumentException("Ambiguous player name; use a UUID: " + target);
|
||||
}
|
||||
List<PlayerLeafState> knownMatches = matchingKnownNames(target);
|
||||
if (knownMatches.size() == 1) {
|
||||
return knownMatches.get(0).playerId();
|
||||
}
|
||||
if (knownMatches.size() > 1) {
|
||||
throw new IllegalArgumentException("Ambiguous previous name; use a UUID: " + target);
|
||||
}
|
||||
throw new IllegalArgumentException("Unknown Leaf player: " + target);
|
||||
}
|
||||
}
|
||||
|
||||
public List<String> knownTargets() {
|
||||
return stateManager.players().values().stream()
|
||||
.flatMap(player -> {
|
||||
ArrayList<String> values = new ArrayList<>(player.knownNames());
|
||||
values.add(player.playerId().toString());
|
||||
return values.stream();
|
||||
})
|
||||
.distinct()
|
||||
.sorted(String.CASE_INSENSITIVE_ORDER)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public void reconcile(Player player) {
|
||||
PlayerLeafState state = stateManager.find(player.getUniqueId()).orElse(null);
|
||||
LeafSettings settings = settingsProvider.current();
|
||||
if (state != null && state.optedIn() && settings.enabled()) {
|
||||
protection.apply(player, settings.resistanceLevel());
|
||||
identity.apply(player, settings.prefix());
|
||||
if (protection.apply(player, settings.resistanceLevel())) {
|
||||
identity.apply(player, settings.prefix());
|
||||
} else {
|
||||
identity.remove(player);
|
||||
}
|
||||
} else {
|
||||
removePresentation(player);
|
||||
}
|
||||
@@ -138,6 +234,12 @@ public final class LeafRuntime {
|
||||
identity.remove(player);
|
||||
}
|
||||
|
||||
public void reconcileAllOnline() {
|
||||
for (Player player : server.getOnlinePlayers()) {
|
||||
reconcile(player);
|
||||
}
|
||||
}
|
||||
|
||||
public LeafStateManager stateManager() {
|
||||
return stateManager;
|
||||
}
|
||||
@@ -146,6 +248,22 @@ public final class LeafRuntime {
|
||||
return settingsProvider.current();
|
||||
}
|
||||
|
||||
private List<PlayerLeafState> matchingLatestNames(String target) {
|
||||
return stateManager.players().values().stream()
|
||||
.filter(player -> player.latestName().equalsIgnoreCase(target))
|
||||
.sorted(Comparator.comparing(player -> player.playerId().toString()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<PlayerLeafState> matchingKnownNames(String target) {
|
||||
return stateManager.players().values().stream()
|
||||
.filter(player -> player.knownNames().stream().anyMatch(
|
||||
name -> name.equalsIgnoreCase(target)
|
||||
))
|
||||
.sorted(Comparator.comparing(player -> player.playerId().toString()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private PlayerLeafState requiredState(UUID playerId) {
|
||||
return stateManager.find(playerId).orElseThrow(
|
||||
() -> new IllegalArgumentException("unknown player: " + playerId)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package games.dmg.leaf;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
public record PlayerLeafState(
|
||||
@@ -8,7 +10,8 @@ public record PlayerLeafState(
|
||||
String latestName,
|
||||
boolean optedIn,
|
||||
boolean locked,
|
||||
Instant firstJoin
|
||||
Instant firstJoin,
|
||||
Set<String> knownNames
|
||||
) {
|
||||
public PlayerLeafState {
|
||||
if (playerId == null) {
|
||||
@@ -20,6 +23,26 @@ public record PlayerLeafState(
|
||||
if (firstJoin == null) {
|
||||
throw new IllegalArgumentException("first join time is required");
|
||||
}
|
||||
HashSet<String> validatedNames = new HashSet<>();
|
||||
if (knownNames != null) {
|
||||
for (String name : knownNames) {
|
||||
if (name != null && !name.isBlank()) {
|
||||
validatedNames.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
validatedNames.add(latestName);
|
||||
knownNames = Set.copyOf(validatedNames);
|
||||
}
|
||||
|
||||
public PlayerLeafState(
|
||||
UUID playerId,
|
||||
String latestName,
|
||||
boolean optedIn,
|
||||
boolean locked,
|
||||
Instant firstJoin
|
||||
) {
|
||||
this(playerId, latestName, optedIn, locked, firstJoin, Set.of(latestName));
|
||||
}
|
||||
|
||||
public static PlayerLeafState newPlayer(UUID playerId, String latestName, Instant joinedAt) {
|
||||
@@ -27,14 +50,16 @@ public record PlayerLeafState(
|
||||
}
|
||||
|
||||
public PlayerLeafState withLatestName(String name) {
|
||||
return new PlayerLeafState(playerId, name, optedIn, locked, firstJoin);
|
||||
HashSet<String> names = new HashSet<>(knownNames);
|
||||
names.add(name);
|
||||
return new PlayerLeafState(playerId, name, optedIn, locked, firstJoin, names);
|
||||
}
|
||||
|
||||
public PlayerLeafState withOptedIn(boolean enabled) {
|
||||
return new PlayerLeafState(playerId, latestName, enabled, locked, firstJoin);
|
||||
return new PlayerLeafState(playerId, latestName, enabled, locked, firstJoin, knownNames);
|
||||
}
|
||||
|
||||
public PlayerLeafState withLocked(boolean newLocked) {
|
||||
return new PlayerLeafState(playerId, latestName, optedIn, newLocked, firstJoin);
|
||||
return new PlayerLeafState(playerId, latestName, optedIn, newLocked, firstJoin, knownNames);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ import java.nio.file.StandardCopyOption;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.configuration.InvalidConfigurationException;
|
||||
@@ -87,7 +89,8 @@ public final class YamlLeafStateRepository {
|
||||
name,
|
||||
yaml.getBoolean(path + ".opted-in", false),
|
||||
yaml.getBoolean(path + ".locked", false),
|
||||
Instant.parse(firstJoinValue)
|
||||
Instant.parse(firstJoinValue),
|
||||
Set.copyOf(yaml.getStringList(path + ".known-names"))
|
||||
);
|
||||
players.put(playerId, player);
|
||||
} catch (IllegalArgumentException | DateTimeParseException ignored) {
|
||||
@@ -126,6 +129,8 @@ public final class YamlLeafStateRepository {
|
||||
yaml.set(path + ".opted-in", player.optedIn());
|
||||
yaml.set(path + ".locked", player.locked());
|
||||
yaml.set(path + ".first-join", player.firstJoin().toString());
|
||||
List<String> knownNames = player.knownNames().stream().sorted().toList();
|
||||
yaml.set(path + ".known-names", knownNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -40,12 +41,80 @@ final class LeafCommandTest {
|
||||
assertTrue(leaf.onCommand(player, command, "leaf", new String[] {"off"}));
|
||||
assertTrue(leaf.onCommand(player, command, "leaf", new String[] {"status"}));
|
||||
|
||||
verify(player, atLeastOnce()).sendMessage(contains("enabled"));
|
||||
verify(player, atLeastOnce()).sendMessage(contains("changed to on"));
|
||||
verify(player, atLeastOnce()).sendMessage(contains("locked"));
|
||||
verify(player, atLeastOnce()).sendMessage(contains("Saved choice: on"));
|
||||
verify(player, atLeastOnce()).sendMessage(contains("active: yes"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void administratorCanMutateGlobalStrengthAndTargetedSettings() throws Exception {
|
||||
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||
UUID targetId = UUID.randomUUID();
|
||||
PlayerLeafState state = new PlayerLeafState(
|
||||
targetId,
|
||||
"Alex",
|
||||
false,
|
||||
false,
|
||||
Instant.parse("2026-08-10T00:00:00Z")
|
||||
);
|
||||
when(runtime.resolveTarget("Alex")).thenReturn(targetId);
|
||||
when(runtime.playerState(targetId)).thenReturn(state);
|
||||
when(runtime.setGlobalEnabled(false)).thenReturn(LeafRuntime.Change.CHANGED);
|
||||
when(runtime.setResistanceLevel(5)).thenReturn(LeafRuntime.Change.CHANGED);
|
||||
when(runtime.setChoice(targetId, true)).thenReturn(LeafRuntime.Change.CHANGED);
|
||||
CommandSender admin = mock(CommandSender.class);
|
||||
when(admin.hasPermission("leaf.admin")).thenReturn(true);
|
||||
LeafCommand leaf = new LeafCommand(runtime);
|
||||
Command command = mock(Command.class);
|
||||
|
||||
leaf.onCommand(admin, command, "leaf", new String[] {"enabled", "off"});
|
||||
leaf.onCommand(admin, command, "leaf", new String[] {"strength", "5"});
|
||||
leaf.onCommand(
|
||||
admin,
|
||||
command,
|
||||
"leaf",
|
||||
new String[] {"player", "Alex", "enabled", "on"}
|
||||
);
|
||||
|
||||
verify(runtime).setGlobalEnabled(false);
|
||||
verify(runtime).setResistanceLevel(5);
|
||||
verify(runtime).setChoice(targetId, true);
|
||||
verify(admin, atLeastOnce()).sendMessage(contains("changed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void administrativeCompletionIsPermissionAwareAndPositionSpecific() {
|
||||
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||
when(runtime.knownTargets()).thenReturn(List.of("Alex", "1234"));
|
||||
CommandSender admin = mock(CommandSender.class);
|
||||
when(admin.hasPermission("leaf.admin")).thenReturn(true);
|
||||
LeafCommand leaf = new LeafCommand(runtime);
|
||||
Command command = mock(Command.class);
|
||||
|
||||
assertEquals(
|
||||
List.of("enabled", "strength", "player"),
|
||||
leaf.onTabComplete(admin, command, "leaf", new String[] {""})
|
||||
);
|
||||
assertEquals(
|
||||
List.of("Alex"),
|
||||
leaf.onTabComplete(admin, command, "leaf", new String[] {"player", "A"})
|
||||
);
|
||||
assertEquals(
|
||||
List.of("status", "enabled", "locked"),
|
||||
leaf.onTabComplete(admin, command, "leaf", new String[] {"player", "Alex", ""})
|
||||
);
|
||||
assertEquals(
|
||||
List.of("on", "off"),
|
||||
leaf.onTabComplete(
|
||||
admin,
|
||||
command,
|
||||
"leaf",
|
||||
new String[] {"player", "Alex", "locked", ""}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionIncludesOnlyPlayerArgumentsAllowedToSender() {
|
||||
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||
|
||||
@@ -2,6 +2,7 @@ package games.dmg.leaf;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -12,6 +13,8 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.bukkit.Server;
|
||||
@@ -87,6 +90,71 @@ final class LeafRuntimeTest {
|
||||
verify(player, never()).sendMessage(contains("Leaf protection is available"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistsGlobalSettingsAndImmediatelyReconcilesOnlinePlayers() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = player(playerId, "Alex");
|
||||
Server server = mock(Server.class);
|
||||
when(server.getPlayer(playerId)).thenReturn(player);
|
||||
org.mockito.Mockito.doReturn(List.of(player)).when(server).getOnlinePlayers();
|
||||
LeafProtection protection = mock(LeafProtection.class);
|
||||
when(protection.apply(player, 1)).thenReturn(true);
|
||||
when(protection.apply(player, 4)).thenReturn(true);
|
||||
LeafIdentity identity = mock(LeafIdentity.class);
|
||||
LeafSettingsProvider settings = new LeafSettingsProvider(LeafSettings.from(Map.of()));
|
||||
ArrayList<LeafSettings> persisted = new ArrayList<>();
|
||||
LeafRuntime runtime = new LeafRuntime(
|
||||
server,
|
||||
settings,
|
||||
new LeafStateManager(new YamlLeafStateRepository(
|
||||
temporaryDirectory.resolve("settings.yml")
|
||||
)),
|
||||
protection,
|
||||
identity,
|
||||
persisted::add
|
||||
);
|
||||
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
|
||||
runtime.setChoice(playerId, true);
|
||||
|
||||
assertEquals(LeafRuntime.Change.CHANGED, runtime.setGlobalEnabled(false));
|
||||
assertEquals(LeafRuntime.Change.CHANGED, runtime.setGlobalEnabled(true));
|
||||
assertEquals(LeafRuntime.Change.CHANGED, runtime.setResistanceLevel(4));
|
||||
|
||||
assertEquals(3, persisted.size());
|
||||
assertFalse(persisted.get(0).enabled());
|
||||
assertEquals(4, settings.current().resistanceLevel());
|
||||
verify(protection, org.mockito.Mockito.atLeastOnce()).remove(player);
|
||||
verify(protection).apply(player, 4);
|
||||
verify(identity, org.mockito.Mockito.atLeastOnce()).remove(player);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesLatestPreviousNamesAndUuidsWithoutGuessingAmbiguities() throws Exception {
|
||||
Server server = mock(Server.class);
|
||||
LeafRuntime runtime = runtime(
|
||||
server,
|
||||
mock(LeafProtection.class),
|
||||
temporaryDirectory.resolve("targets.yml")
|
||||
);
|
||||
UUID firstId = UUID.randomUUID();
|
||||
Player first = player(firstId, "Shared");
|
||||
runtime.observe(first, Instant.parse("2026-08-01T00:00:00Z"));
|
||||
when(first.getName()).thenReturn("Alex");
|
||||
runtime.observe(first, Instant.parse("2026-08-02T00:00:00Z"));
|
||||
|
||||
assertEquals(firstId, runtime.resolveTarget("Alex"));
|
||||
assertEquals(firstId, runtime.resolveTarget("Shared"));
|
||||
assertEquals(firstId, runtime.resolveTarget(firstId.toString()));
|
||||
|
||||
UUID secondId = UUID.randomUUID();
|
||||
Player second = player(secondId, "Shared");
|
||||
runtime.observe(second, Instant.parse("2026-08-03T00:00:00Z"));
|
||||
when(second.getName()).thenReturn("Steve");
|
||||
runtime.observe(second, Instant.parse("2026-08-04T00:00:00Z"));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> runtime.resolveTarget("Shared"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lockedPlayerCannotChangeTheirChoice() throws Exception {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
|
||||
Reference in New Issue
Block a user