feat(admin): add Leaf administration commands
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
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 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) {
|
if (change == LeafRuntime.Change.LOCKED) {
|
||||||
player.sendMessage(LeafText.color(runtime.settings().lockedMessage()));
|
player.sendMessage(LeafText.color(runtime.settings().lockedMessage()));
|
||||||
} else if (change == LeafRuntime.Change.UNCHANGED) {
|
|
||||||
player.sendMessage("Leaf was already " + (enabled ? "enabled" : "disabled") + ".");
|
|
||||||
} else {
|
} else {
|
||||||
player.sendMessage("Leaf protection is now " + (enabled ? "enabled" : "disabled") + ".");
|
player.sendMessage(changeMessage("Leaf protection", enabled, change));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void reportStatus(Player player) {
|
private void reportStatus(CommandSender sender, UUID playerId, boolean includeIdentity) {
|
||||||
LeafRuntime.Status status = runtime.status(player.getUniqueId());
|
LeafRuntime.Status status = runtime.status(playerId);
|
||||||
player.sendMessage(
|
String identity = "";
|
||||||
"Leaf status — Saved choice: " + onOff(status.savedChoice())
|
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<>();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user