feat(admin): add Leaf administration commands

This commit is contained in:
dmg
2026-08-10 18:29:41 -04:00
parent 5d3acef8ab
commit e53548f481
11 changed files with 497 additions and 60 deletions
+8
View File
@@ -56,3 +56,11 @@
- The exact expiration boundary and globally disabled behavior suppress reminders without resetting first-join state. - 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. - 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`. - 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`.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-003: Identify protected players" title: "US-003: Identify protected players"
description: Mark actively protected players with a visible leaf prefix wherever Spigot supports it. description: Mark actively protected players with a visible leaf prefix wherever Spigot supports it.
status: in-progress status: done
--- ---
# US-003: Identify protected players # 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 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] 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. - [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. - [x] 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] 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] 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. - [x] Scoreboard-team and third-party chat-plugin compatibility limitations are documented, and unsupported integrations fail without affecting protection state.
+14 -14
View File
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-005: Administer Leaf" title: "US-005: Administer Leaf"
description: Give administrators command-based control over global and per-player Leaf behavior. description: Give administrators command-based control over global and per-player Leaf behavior.
status: backlog status: done
--- ---
# US-005: Administer Leaf # US-005: Administer Leaf
@@ -11,19 +11,19 @@ As a **server administrator**, I want to control Leaf globally and per player so
## Acceptance criteria ## Acceptance criteria
- [ ] Administrative commands require `leaf.admin`, which server operators receive by default. - [x] Administrative commands require `leaf.admin`, which server operators receive by default.
- [ ] `/leaf enabled <on|off>` globally enables or disables Leaf. - [x] `/leaf enabled <on|off>` globally enables or disables Leaf.
- [ ] Global disablement immediately suppresses Leaf-managed Resistance, prefixes, and onboarding reminders without erasing player opt-in choices. - [x] 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. - [x] Global enablement immediately restores protection and prefixes for eligible online opted-in players.
- [ ] `/leaf player <name|uuid> status` reports the player's identity, saved opt-in choice, active-protection state, lock state, and first-join time. - [x] `/leaf player <name|uuid> status` reports the player's identity, saved opt-in choice, active-protection state, lock state, and first-join time.
- [ ] `/leaf player <name|uuid> enabled <on|off>` changes the player's saved opt-in choice and immediately reconciles their online effect and prefix. - [x] `/leaf player <name|uuid> enabled <on|off>` changes the player's saved opt-in choice and immediately reconciles their online effect and prefix.
- [ ] `/leaf player <name|uuid> locked <on|off>` controls whether that player can change their own saved choice. - [x] `/leaf player <name|uuid> locked <on|off>` 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. - [x] 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. - [x] 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. - [x] 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. - [x] `/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. - [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.
- [ ] Every successful mutation reports exactly what changed, and invalid requests make no partial changes. - [x] Every successful mutation reports exactly what changed, and invalid requests make no partial changes.
## Related ## Related
+169 -31
View File
@@ -4,6 +4,7 @@ import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.UUID;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.TabExecutor; import org.bukkit.command.TabExecutor;
@@ -12,6 +13,10 @@ import org.bukkit.entity.Player;
/** Implements the permission-aware /leaf command tree. */ /** Implements the permission-aware /leaf command tree. */
public final class LeafCommand implements TabExecutor { public final class LeafCommand implements TabExecutor {
private static final List<String> PLAYER_COMMANDS = List.of("on", "off", "status"); 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; private final LeafRuntime runtime;
public LeafCommand(LeafRuntime runtime) { public LeafCommand(LeafRuntime runtime) {
@@ -25,29 +30,21 @@ public final class LeafCommand implements TabExecutor {
String label, String label,
String[] arguments String[] arguments
) { ) {
if (arguments.length != 1 || !PLAYER_COMMANDS.contains(arguments[0].toLowerCase(Locale.ROOT))) { if (arguments.length == 0) {
sender.sendMessage("Usage: /leaf <on|off|status>"); sendUsage(sender);
return true; 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); String action = arguments[0].toLowerCase(Locale.ROOT);
try { try {
switch (action) { if (PLAYER_COMMANDS.contains(action)) {
case "on" -> reportChoice(player, true, runtime.setOwnChoice(player, true)); executePlayer(sender, action, arguments);
case "off" -> reportChoice(player, false, runtime.setOwnChoice(player, false)); } else if (ADMIN_COMMANDS.contains(action)) {
case "status" -> reportStatus(player); executeAdmin(sender, action, arguments);
default -> throw new IllegalStateException("validated action was not handled"); } else {
sendUsage(sender);
} }
} catch (IOException | IllegalArgumentException exception) { } 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; return true;
} }
@@ -59,32 +56,173 @@ public final class LeafCommand implements TabExecutor {
String alias, String alias,
String[] arguments 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 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) { private void executePlayer(CommandSender sender, String action, String[] arguments)
if (change == LeafRuntime.Change.LOCKED) { throws IOException {
player.sendMessage(LeafText.color(runtime.settings().lockedMessage())); if (arguments.length != 1) {
} else if (change == LeafRuntime.Change.UNCHANGED) { throw new IllegalArgumentException("Usage: /leaf <on|off|status>");
player.sendMessage("Leaf was already " + (enabled ? "enabled" : "disabled") + "."); }
} else { if (!(sender instanceof Player player)) {
player.sendMessage("Leaf protection is now " + (enabled ? "enabled" : "disabled") + "."); 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) { private void executeAdmin(CommandSender sender, String action, String[] arguments)
LeafRuntime.Status status = runtime.status(player.getUniqueId()); throws IOException {
player.sendMessage( if (!sender.hasPermission("leaf.admin")) {
"Leaf status — Saved choice: " + onOff(status.savedChoice()) 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()) + "; active: " + yesNo(status.activeProtection())
+ "; locked: " + yesNo(status.locked()) + "; 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) { static List<String> matching(List<String> candidates, String partial) {
String normalized = partial.toLowerCase(Locale.ROOT); String normalized = partial.toLowerCase(Locale.ROOT);
List<String> matches = new ArrayList<>(); List<String> matches = new ArrayList<>();
+3 -3
View File
@@ -27,7 +27,8 @@ public final class LeafPlugin extends JavaPlugin {
settingsProvider, settingsProvider,
stateManager, stateManager,
new LeafProtection(), new LeafProtection(),
new LeafIdentity(getServer(), getLogger()) new LeafIdentity(getServer(), getLogger()),
this::persistRuntimeSettings
); );
registerRuntime(); registerRuntime();
} catch (IllegalArgumentException | IOException exception) { } catch (IllegalArgumentException | IOException exception) {
@@ -75,8 +76,7 @@ public final class LeafPlugin extends JavaPlugin {
return stateManager; return stateManager;
} }
void persistRuntimeSettings() { void persistRuntimeSettings(LeafSettings settings) {
LeafSettings settings = settingsProvider.current();
getConfig().set("enabled", settings.enabled()); getConfig().set("enabled", settings.enabled());
getConfig().set("resistance-level", settings.resistanceLevel()); getConfig().set("resistance-level", settings.resistanceLevel());
saveConfig(); 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) { public synchronized boolean owns(Player player) {
PotionEffect expected = appliedEffects.get(player.getUniqueId()); PotionEffect expected = appliedEffects.get(player.getUniqueId());
return expected != null return expected != null
+121 -3
View File
@@ -3,6 +3,9 @@ package games.dmg.leaf;
import java.io.IOException; import java.io.IOException;
import java.time.Instant; import java.time.Instant;
import java.time.temporal.ChronoUnit; import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import org.bukkit.Server; import org.bukkit.Server;
@@ -10,6 +13,11 @@ import org.bukkit.entity.Player;
/** Coordinates durable player intent with live Leaf-managed protection. */ /** Coordinates durable player intent with live Leaf-managed protection. */
public final class LeafRuntime { public final class LeafRuntime {
@FunctionalInterface
public interface SettingsPersistence {
void save(LeafSettings settings) throws IOException;
}
public enum Change { public enum Change {
CHANGED, CHANGED,
UNCHANGED, UNCHANGED,
@@ -29,6 +37,7 @@ public final class LeafRuntime {
private final LeafStateManager stateManager; private final LeafStateManager stateManager;
private final LeafProtection protection; private final LeafProtection protection;
private final LeafIdentity identity; private final LeafIdentity identity;
private final SettingsPersistence settingsPersistence;
public LeafRuntime( public LeafRuntime(
Server server, Server server,
@@ -36,12 +45,27 @@ public final class LeafRuntime {
LeafStateManager stateManager, LeafStateManager stateManager,
LeafProtection protection, LeafProtection protection,
LeafIdentity identity 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.server = Objects.requireNonNull(server, "server");
this.settingsProvider = Objects.requireNonNull(settingsProvider, "settingsProvider"); this.settingsProvider = Objects.requireNonNull(settingsProvider, "settingsProvider");
this.stateManager = Objects.requireNonNull(stateManager, "stateManager"); this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
this.protection = Objects.requireNonNull(protection, "protection"); this.protection = Objects.requireNonNull(protection, "protection");
this.identity = Objects.requireNonNull(identity, "identity"); this.identity = Objects.requireNonNull(identity, "identity");
this.settingsPersistence = Objects.requireNonNull(
settingsPersistence,
"settingsPersistence"
);
} }
public PlayerLeafState observe(Player player, Instant observedAt) throws IOException { public PlayerLeafState observe(Player player, Instant observedAt) throws IOException {
@@ -95,6 +119,30 @@ public final class LeafRuntime {
return changed; 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 { public Change setLocked(UUID playerId, boolean locked) throws IOException {
PlayerLeafState state = requiredState(playerId); PlayerLeafState state = requiredState(playerId);
if (state.locked() == locked) { if (state.locked() == locked) {
@@ -112,7 +160,7 @@ public final class LeafRuntime {
boolean active = online != null boolean active = online != null
&& globallyEnabled && globallyEnabled
&& state.optedIn() && state.optedIn()
&& protection.owns(online); && protection.isEffective(online, settingsProvider.current().resistanceLevel());
return new Status( return new Status(
state.optedIn(), state.optedIn(),
active, 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) { public void reconcile(Player player) {
PlayerLeafState state = stateManager.find(player.getUniqueId()).orElse(null); PlayerLeafState state = stateManager.find(player.getUniqueId()).orElse(null);
LeafSettings settings = settingsProvider.current(); LeafSettings settings = settingsProvider.current();
if (state != null && state.optedIn() && settings.enabled()) { if (state != null && state.optedIn() && settings.enabled()) {
protection.apply(player, settings.resistanceLevel()); if (protection.apply(player, settings.resistanceLevel())) {
identity.apply(player, settings.prefix()); identity.apply(player, settings.prefix());
} else {
identity.remove(player);
}
} else { } else {
removePresentation(player); removePresentation(player);
} }
@@ -138,6 +234,12 @@ public final class LeafRuntime {
identity.remove(player); identity.remove(player);
} }
public void reconcileAllOnline() {
for (Player player : server.getOnlinePlayers()) {
reconcile(player);
}
}
public LeafStateManager stateManager() { public LeafStateManager stateManager() {
return stateManager; return stateManager;
} }
@@ -146,6 +248,22 @@ public final class LeafRuntime {
return settingsProvider.current(); 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) { private PlayerLeafState requiredState(UUID playerId) {
return stateManager.find(playerId).orElseThrow( return stateManager.find(playerId).orElseThrow(
() -> new IllegalArgumentException("unknown player: " + playerId) () -> new IllegalArgumentException("unknown player: " + playerId)
@@ -1,6 +1,8 @@
package games.dmg.leaf; package games.dmg.leaf;
import java.time.Instant; import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
public record PlayerLeafState( public record PlayerLeafState(
@@ -8,7 +10,8 @@ public record PlayerLeafState(
String latestName, String latestName,
boolean optedIn, boolean optedIn,
boolean locked, boolean locked,
Instant firstJoin Instant firstJoin,
Set<String> knownNames
) { ) {
public PlayerLeafState { public PlayerLeafState {
if (playerId == null) { if (playerId == null) {
@@ -20,6 +23,26 @@ public record PlayerLeafState(
if (firstJoin == null) { if (firstJoin == null) {
throw new IllegalArgumentException("first join time is required"); 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) { public static PlayerLeafState newPlayer(UUID playerId, String latestName, Instant joinedAt) {
@@ -27,14 +50,16 @@ public record PlayerLeafState(
} }
public PlayerLeafState withLatestName(String name) { 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) { 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) { 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.Instant;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.InvalidConfigurationException;
@@ -87,7 +89,8 @@ public final class YamlLeafStateRepository {
name, name,
yaml.getBoolean(path + ".opted-in", false), yaml.getBoolean(path + ".opted-in", false),
yaml.getBoolean(path + ".locked", false), yaml.getBoolean(path + ".locked", false),
Instant.parse(firstJoinValue) Instant.parse(firstJoinValue),
Set.copyOf(yaml.getStringList(path + ".known-names"))
); );
players.put(playerId, player); players.put(playerId, player);
} catch (IllegalArgumentException | DateTimeParseException ignored) { } catch (IllegalArgumentException | DateTimeParseException ignored) {
@@ -126,6 +129,8 @@ public final class YamlLeafStateRepository {
yaml.set(path + ".opted-in", player.optedIn()); yaml.set(path + ".opted-in", player.optedIn());
yaml.set(path + ".locked", player.locked()); yaml.set(path + ".locked", player.locked());
yaml.set(path + ".first-join", player.firstJoin().toString()); 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.List;
import java.util.UUID; import java.util.UUID;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test; 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[] {"off"}));
assertTrue(leaf.onCommand(player, command, "leaf", new String[] {"status"})); 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("locked"));
verify(player, atLeastOnce()).sendMessage(contains("Saved choice: on")); verify(player, atLeastOnce()).sendMessage(contains("Saved choice: on"));
verify(player, atLeastOnce()).sendMessage(contains("active: yes")); 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 @Test
void completionIncludesOnlyPlayerArgumentsAllowedToSender() { void completionIncludesOnlyPlayerArgumentsAllowedToSender() {
LeafRuntime runtime = mock(LeafRuntime.class); 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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; 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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
@@ -12,6 +13,8 @@ import static org.mockito.Mockito.when;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.bukkit.Server; import org.bukkit.Server;
@@ -87,6 +90,71 @@ final class LeafRuntimeTest {
verify(player, never()).sendMessage(contains("Leaf protection is available")); 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 @Test
void lockedPlayerCannotChangeTheirChoice() throws Exception { void lockedPlayerCannotChangeTheirChoice() throws Exception {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();