From e53548f481df092b837797011aae08fd7e60cfce Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 10 Aug 2026 18:29:41 -0400 Subject: [PATCH] feat(admin): add Leaf administration commands --- design/log.md | 8 + .../us-003-identify-protected-players.md | 6 +- design/user-stories/us-005-administer-leaf.md | 28 +-- src/main/java/games/dmg/leaf/LeafCommand.java | 200 +++++++++++++++--- src/main/java/games/dmg/leaf/LeafPlugin.java | 6 +- .../java/games/dmg/leaf/LeafProtection.java | 6 + src/main/java/games/dmg/leaf/LeafRuntime.java | 124 ++++++++++- .../java/games/dmg/leaf/PlayerLeafState.java | 33 ++- .../dmg/leaf/YamlLeafStateRepository.java | 7 +- .../java/games/dmg/leaf/LeafCommandTest.java | 71 ++++++- .../java/games/dmg/leaf/LeafRuntimeTest.java | 68 ++++++ 11 files changed, 497 insertions(+), 60 deletions(-) diff --git a/design/log.md b/design/log.md index c208985..c88c017 100644 --- a/design/log.md +++ b/design/log.md @@ -56,3 +56,11 @@ - The exact expiration boundary and globally disabled behavior suppress reminders without resetting first-join state. - The default message names all player commands and explains automatic combat opt-out. - Verified repeated joins, expiration, global suppression, first-join retention, and the full build with `./gradlew clean check jar`. + +### US-005 administration and US-003 identity completed + +- Added the complete `leaf.admin` command tree for global state, live strength, player status, saved choice, and locks. +- Runtime global and strength changes persist before activation and immediately reconcile all online players. +- Target resolution prefers exact online/current names, retains prior names, accepts UUIDs, and rejects ambiguous reused names. +- Added position-specific administrative autocomplete and mutation/no-change reporting with validation before mutation. +- Verified global effect and prefix suppression/restoration, live strength, targets, locks, command permissions, completion, durable aliases, and the full build with `./gradlew clean check jar`. diff --git a/design/user-stories/us-003-identify-protected-players.md b/design/user-stories/us-003-identify-protected-players.md index eddeb3a..d2b33f9 100644 --- a/design/user-stories/us-003-identify-protected-players.md +++ b/design/user-stories/us-003-identify-protected-players.md @@ -2,7 +2,7 @@ type: User Story title: "US-003: Identify protected players" description: Mark actively protected players with a visible leaf prefix wherever Spigot supports it. -status: in-progress +status: done --- # US-003: Identify protected players @@ -15,8 +15,8 @@ As a **server participant**, I want protected players to be visibly identified s - [x] An actively protected player has the same prefix before their overhead name where Spigot supports it. - [x] An actively protected player has the same prefix in standard Spigot chat where the active chat format supports it. - [x] The default prefix uses a leaf symbol with readable spacing and Minecraft-compatible formatting. -- [ ] The prefix is removed immediately when the player opts out or is opted out by PvP or an administrator. -- [ ] Prefixes are suppressed while Leaf is globally disabled and restored for online opted-in players when it is re-enabled. +- [x] The prefix is removed immediately when the player opts out or is opted out by PvP or an administrator. +- [x] Prefixes are suppressed while Leaf is globally disabled and restored for online opted-in players when it is re-enabled. - [x] Prefix updates do not overwrite unrelated display-name text where the Spigot API allows the values to coexist. - [x] Scoreboard-team and third-party chat-plugin compatibility limitations are documented, and unsupported integrations fail without affecting protection state. diff --git a/design/user-stories/us-005-administer-leaf.md b/design/user-stories/us-005-administer-leaf.md index 9547352..c2cfe32 100644 --- a/design/user-stories/us-005-administer-leaf.md +++ b/design/user-stories/us-005-administer-leaf.md @@ -2,7 +2,7 @@ type: User Story title: "US-005: Administer Leaf" description: Give administrators command-based control over global and per-player Leaf behavior. -status: backlog +status: done --- # US-005: Administer Leaf @@ -11,19 +11,19 @@ As a **server administrator**, I want to control Leaf globally and per player so ## Acceptance criteria -- [ ] Administrative commands require `leaf.admin`, which server operators receive by default. -- [ ] `/leaf enabled ` globally enables or disables Leaf. -- [ ] Global disablement immediately suppresses Leaf-managed Resistance, prefixes, and onboarding reminders without erasing player opt-in choices. -- [ ] Global enablement immediately restores protection and prefixes for eligible online opted-in players. -- [ ] `/leaf player status` reports the player's identity, saved opt-in choice, active-protection state, lock state, and first-join time. -- [ ] `/leaf player enabled ` changes the player's saved opt-in choice and immediately reconciles their online effect and prefix. -- [ ] `/leaf player locked ` controls whether that player can change their own saved choice. -- [ ] A locked player receives a clear explanation when `/leaf on` or `/leaf off` is denied. -- [ ] PvP-triggered opt-out remains authoritative even for a locked player. -- [ ] Player targets resolve online names, previously known names, and UUIDs without confusing two players who used the same name. -- [ ] `/leaf strength <1-5>` changes the live Resistance level and immediately updates protected online players. -- [ ] Administrative autocomplete is permission-aware and suggests valid subcommands, known player targets, properties, levels, and `on` or `off` values for the current argument position. -- [ ] Every successful mutation reports exactly what changed, and invalid requests make no partial changes. +- [x] Administrative commands require `leaf.admin`, which server operators receive by default. +- [x] `/leaf enabled ` globally enables or disables Leaf. +- [x] Global disablement immediately suppresses Leaf-managed Resistance, prefixes, and onboarding reminders without erasing player opt-in choices. +- [x] Global enablement immediately restores protection and prefixes for eligible online opted-in players. +- [x] `/leaf player status` reports the player's identity, saved opt-in choice, active-protection state, lock state, and first-join time. +- [x] `/leaf player enabled ` changes the player's saved opt-in choice and immediately reconciles their online effect and prefix. +- [x] `/leaf player locked ` controls whether that player can change their own saved choice. +- [x] A locked player receives a clear explanation when `/leaf on` or `/leaf off` is denied. +- [x] PvP-triggered opt-out remains authoritative even for a locked player. +- [x] Player targets resolve online names, previously known names, and UUIDs without confusing two players who used the same name. +- [x] `/leaf strength <1-5>` changes the live Resistance level and immediately updates protected online players. +- [x] Administrative autocomplete is permission-aware and suggests valid subcommands, known player targets, properties, levels, and `on` or `off` values for the current argument position. +- [x] Every successful mutation reports exactly what changed, and invalid requests make no partial changes. ## Related diff --git a/src/main/java/games/dmg/leaf/LeafCommand.java b/src/main/java/games/dmg/leaf/LeafCommand.java index 968eabd..f6a2214 100644 --- a/src/main/java/games/dmg/leaf/LeafCommand.java +++ b/src/main/java/games/dmg/leaf/LeafCommand.java @@ -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 PLAYER_COMMANDS = List.of("on", "off", "status"); + private static final List ADMIN_COMMANDS = List.of("enabled", "strength", "player"); + private static final List ON_OFF = List.of("on", "off"); + private static final List LEVELS = List.of("1", "2", "3", "4", "5"); + private static final List 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 "); + 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 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 "); + } + 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 "); + } + 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 [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 [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 "); + } + static List matching(List candidates, String partial) { String normalized = partial.toLowerCase(Locale.ROOT); List matches = new ArrayList<>(); diff --git a/src/main/java/games/dmg/leaf/LeafPlugin.java b/src/main/java/games/dmg/leaf/LeafPlugin.java index d41688c..783f10c 100644 --- a/src/main/java/games/dmg/leaf/LeafPlugin.java +++ b/src/main/java/games/dmg/leaf/LeafPlugin.java @@ -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(); diff --git a/src/main/java/games/dmg/leaf/LeafProtection.java b/src/main/java/games/dmg/leaf/LeafProtection.java index 917f3e3..190949e 100644 --- a/src/main/java/games/dmg/leaf/LeafProtection.java +++ b/src/main/java/games/dmg/leaf/LeafProtection.java @@ -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 diff --git a/src/main/java/games/dmg/leaf/LeafRuntime.java b/src/main/java/games/dmg/leaf/LeafRuntime.java index 7359ac6..81709c7 100644 --- a/src/main/java/games/dmg/leaf/LeafRuntime.java +++ b/src/main/java/games/dmg/leaf/LeafRuntime.java @@ -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 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 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 knownTargets() { + return stateManager.players().values().stream() + .flatMap(player -> { + ArrayList 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 matchingLatestNames(String target) { + return stateManager.players().values().stream() + .filter(player -> player.latestName().equalsIgnoreCase(target)) + .sorted(Comparator.comparing(player -> player.playerId().toString())) + .toList(); + } + + private List 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) diff --git a/src/main/java/games/dmg/leaf/PlayerLeafState.java b/src/main/java/games/dmg/leaf/PlayerLeafState.java index d969a82..e2c5f28 100644 --- a/src/main/java/games/dmg/leaf/PlayerLeafState.java +++ b/src/main/java/games/dmg/leaf/PlayerLeafState.java @@ -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 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 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 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); } } diff --git a/src/main/java/games/dmg/leaf/YamlLeafStateRepository.java b/src/main/java/games/dmg/leaf/YamlLeafStateRepository.java index 2b8be82..8839407 100644 --- a/src/main/java/games/dmg/leaf/YamlLeafStateRepository.java +++ b/src/main/java/games/dmg/leaf/YamlLeafStateRepository.java @@ -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 knownNames = player.knownNames().stream().sorted().toList(); + yaml.set(path + ".known-names", knownNames); } } } diff --git a/src/test/java/games/dmg/leaf/LeafCommandTest.java b/src/test/java/games/dmg/leaf/LeafCommandTest.java index 9213978..5247669 100644 --- a/src/test/java/games/dmg/leaf/LeafCommandTest.java +++ b/src/test/java/games/dmg/leaf/LeafCommandTest.java @@ -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); diff --git a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java index bbd0442..80e09d0 100644 --- a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java +++ b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java @@ -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 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();