feat(protection): implement player opt-in lifecycle
This commit is contained in:
@@ -27,3 +27,10 @@
|
|||||||
- Added defensive UUID-keyed YAML player state with RFC 3339 timestamps, forward-field preservation, and atomic replacement where supported.
|
- Added defensive UUID-keyed YAML player state with RFC 3339 timestamps, forward-field preservation, and atomic replacement where supported.
|
||||||
- Added safe plugin initialization and periodic dirty-state persistence.
|
- Added safe plugin initialization and periodic dirty-state persistence.
|
||||||
- US-006 remains in progress pending live runtime setting commands and Leaf effect ownership behavior.
|
- US-006 remains in progress pending live runtime setting commands and Leaf effect ownership behavior.
|
||||||
|
|
||||||
|
### US-001 player protection completed
|
||||||
|
|
||||||
|
- Added permission-aware player commands with idempotent choice changes and detailed status reporting.
|
||||||
|
- Added immediate durable opt-in persistence, join-time restoration, and quiet infinite Resistance reconciliation.
|
||||||
|
- Leaf tracks its live Resistance fingerprint and conservatively preserves a visibly distinct Resistance effect.
|
||||||
|
- Verified player choice, lock, persistence, command, autocomplete, and build behavior with `./gradlew clean check jar`.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: User Story
|
type: User Story
|
||||||
title: "US-001: Opt into Leaf protection"
|
title: "US-001: Opt into Leaf protection"
|
||||||
description: Let players voluntarily receive and relinquish a persistent Resistance boost.
|
description: Let players voluntarily receive and relinquish a persistent Resistance boost.
|
||||||
status: backlog
|
status: done
|
||||||
---
|
---
|
||||||
|
|
||||||
# US-001: Opt into Leaf protection
|
# US-001: Opt into Leaf protection
|
||||||
@@ -11,15 +11,15 @@ As a **player**, I want to opt into Leaf protection so that I can receive a mode
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Players with the `leaf.use` permission, granted by default, can use `/leaf on`, `/leaf off`, and `/leaf status`.
|
- [x] Players with the `leaf.use` permission, granted by default, can use `/leaf on`, `/leaf off`, and `/leaf status`.
|
||||||
- [ ] `/leaf on` records the player's opt-in choice and grants Resistance I while Leaf is globally enabled.
|
- [x] `/leaf on` records the player's opt-in choice and grants Resistance I while Leaf is globally enabled.
|
||||||
- [ ] Resistance remains continuously effective without distracting expiry or renewal messages or particles.
|
- [x] Resistance remains continuously effective without distracting expiry or renewal messages or particles.
|
||||||
- [ ] `/leaf off` records the player's opt-out choice and immediately removes only the Resistance effect managed by Leaf.
|
- [x] `/leaf off` records the player's opt-out choice and immediately removes only the Resistance effect managed by Leaf.
|
||||||
- [ ] `/leaf status` clearly distinguishes the player's saved choice, active protection, administrative lock, and global Leaf state.
|
- [x] `/leaf status` clearly distinguishes the player's saved choice, active protection, administrative lock, and global Leaf state.
|
||||||
- [ ] Repeating an already-satisfied `on` or `off` command is safe and explains that no change was needed.
|
- [x] Repeating an already-satisfied `on` or `off` command is safe and explains that no change was needed.
|
||||||
- [ ] Opt-in choices are keyed by UUID and survive logout and server restart.
|
- [x] Opt-in choices are keyed by UUID and survive logout and server restart.
|
||||||
- [ ] A player who joins while opted in regains protection when Leaf is enabled.
|
- [x] A player who joins while opted in regains protection when Leaf is enabled.
|
||||||
- [ ] Player-command autocomplete suggests only valid next arguments available to the sender.
|
- [x] Player-command autocomplete suggests only valid next arguments available to the sender.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.command.CommandSender;
|
||||||
|
import org.bukkit.command.TabExecutor;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
/** Implements the permission-aware /leaf command tree. */
|
||||||
|
public final class LeafCommand implements TabExecutor {
|
||||||
|
private static final List<String> PLAYER_COMMANDS = List.of("on", "off", "status");
|
||||||
|
private final LeafRuntime runtime;
|
||||||
|
|
||||||
|
public LeafCommand(LeafRuntime runtime) {
|
||||||
|
this.runtime = runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCommand(
|
||||||
|
CommandSender sender,
|
||||||
|
Command command,
|
||||||
|
String label,
|
||||||
|
String[] arguments
|
||||||
|
) {
|
||||||
|
if (arguments.length != 1 || !PLAYER_COMMANDS.contains(arguments[0].toLowerCase(Locale.ROOT))) {
|
||||||
|
sender.sendMessage("Usage: /leaf <on|off|status>");
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
} catch (IOException | IllegalArgumentException exception) {
|
||||||
|
sender.sendMessage("Leaf could not save your request; no partial change was applied.");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> onTabComplete(
|
||||||
|
CommandSender sender,
|
||||||
|
Command command,
|
||||||
|
String alias,
|
||||||
|
String[] arguments
|
||||||
|
) {
|
||||||
|
if (arguments.length != 1 || !sender.hasPermission("leaf.use")) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return matching(PLAYER_COMMANDS, arguments[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reportChoice(Player player, boolean enabled, LeafRuntime.Change change) {
|
||||||
|
if (change == LeafRuntime.Change.LOCKED) {
|
||||||
|
player.sendMessage(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 reportStatus(Player player) {
|
||||||
|
LeafRuntime.Status status = runtime.status(player.getUniqueId());
|
||||||
|
player.sendMessage(
|
||||||
|
"Leaf status — Saved choice: " + onOff(status.savedChoice())
|
||||||
|
+ "; active: " + yesNo(status.activeProtection())
|
||||||
|
+ "; locked: " + yesNo(status.locked())
|
||||||
|
+ "; global: " + onOff(status.globallyEnabled()) + "."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> matching(List<String> candidates, String partial) {
|
||||||
|
String normalized = partial.toLowerCase(Locale.ROOT);
|
||||||
|
List<String> matches = new ArrayList<>();
|
||||||
|
for (String candidate : candidates) {
|
||||||
|
if (candidate.toLowerCase(Locale.ROOT).startsWith(normalized)) {
|
||||||
|
matches.add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String onOff(boolean value) {
|
||||||
|
return value ? "on" : "off";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String yesNo(boolean value) {
|
||||||
|
return value ? "yes" : "no";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.util.logging.Level;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
import org.bukkit.event.EventHandler;
|
||||||
|
import org.bukkit.event.Listener;
|
||||||
|
import org.bukkit.event.player.PlayerJoinEvent;
|
||||||
|
|
||||||
|
/** Handles player lifecycle events that affect Leaf protection. */
|
||||||
|
public final class LeafListener implements Listener {
|
||||||
|
private final LeafRuntime runtime;
|
||||||
|
private final Clock clock;
|
||||||
|
private final Logger logger;
|
||||||
|
|
||||||
|
public LeafListener(LeafRuntime runtime, Clock clock, Logger logger) {
|
||||||
|
this.runtime = runtime;
|
||||||
|
this.clock = clock;
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventHandler
|
||||||
|
public void onJoin(PlayerJoinEvent event) {
|
||||||
|
try {
|
||||||
|
runtime.observe(event.getPlayer(), clock.instant());
|
||||||
|
} catch (IOException exception) {
|
||||||
|
logger.log(Level.SEVERE, "Could not persist Leaf player state on join", exception);
|
||||||
|
runtime.removeProtection(event.getPlayer());
|
||||||
|
event.getPlayer().sendMessage("Leaf protection is unavailable because state could not be saved.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
package games.dmg.leaf;
|
package games.dmg.leaf;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.time.Clock;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.logging.Level;
|
import java.util.logging.Level;
|
||||||
|
import org.bukkit.command.PluginCommand;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
import org.bukkit.plugin.java.JavaPlugin;
|
import org.bukkit.plugin.java.JavaPlugin;
|
||||||
|
|
||||||
public final class LeafPlugin extends JavaPlugin {
|
public final class LeafPlugin extends JavaPlugin {
|
||||||
private LeafSettingsProvider settingsProvider;
|
private LeafSettingsProvider settingsProvider;
|
||||||
private LeafStateManager stateManager;
|
private LeafStateManager stateManager;
|
||||||
|
private LeafRuntime runtime;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -18,6 +22,13 @@ public final class LeafPlugin extends JavaPlugin {
|
|||||||
stateManager = new LeafStateManager(
|
stateManager = new LeafStateManager(
|
||||||
new YamlLeafStateRepository(getDataFolder().toPath().resolve("state.yml"))
|
new YamlLeafStateRepository(getDataFolder().toPath().resolve("state.yml"))
|
||||||
);
|
);
|
||||||
|
runtime = new LeafRuntime(
|
||||||
|
getServer(),
|
||||||
|
settingsProvider,
|
||||||
|
stateManager,
|
||||||
|
new LeafProtection()
|
||||||
|
);
|
||||||
|
registerRuntime();
|
||||||
} catch (IllegalArgumentException | IOException exception) {
|
} catch (IllegalArgumentException | IOException exception) {
|
||||||
getLogger().log(Level.SEVERE, "Could not initialize Leaf", exception);
|
getLogger().log(Level.SEVERE, "Could not initialize Leaf", exception);
|
||||||
getServer().getPluginManager().disablePlugin(this);
|
getServer().getPluginManager().disablePlugin(this);
|
||||||
@@ -30,9 +41,31 @@ public final class LeafPlugin extends JavaPlugin {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDisable() {
|
public void onDisable() {
|
||||||
|
if (runtime != null) {
|
||||||
|
for (Player player : getServer().getOnlinePlayers()) {
|
||||||
|
runtime.removeProtection(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
saveState();
|
saveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void registerRuntime() {
|
||||||
|
LeafCommand leafCommand = new LeafCommand(runtime);
|
||||||
|
PluginCommand command = getCommand("leaf");
|
||||||
|
if (command == null) {
|
||||||
|
throw new IllegalStateException("Leaf command is missing from plugin.yml");
|
||||||
|
}
|
||||||
|
command.setExecutor(leafCommand);
|
||||||
|
command.setTabCompleter(leafCommand);
|
||||||
|
getServer().getPluginManager().registerEvents(
|
||||||
|
new LeafListener(runtime, Clock.systemUTC(), getLogger()),
|
||||||
|
this
|
||||||
|
);
|
||||||
|
for (Player player : getServer().getOnlinePlayers()) {
|
||||||
|
runtime.reconcile(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LeafSettingsProvider settingsProvider() {
|
LeafSettingsProvider settingsProvider() {
|
||||||
return settingsProvider;
|
return settingsProvider;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.bukkit.potion.PotionEffect;
|
||||||
|
import org.bukkit.potion.PotionEffectType;
|
||||||
|
|
||||||
|
/** Applies and conservatively removes the Resistance effect owned by Leaf. */
|
||||||
|
public final class LeafProtection {
|
||||||
|
private final Map<UUID, PotionEffect> appliedEffects = new HashMap<>();
|
||||||
|
|
||||||
|
public synchronized boolean apply(Player player, int level) {
|
||||||
|
PotionEffect desired = effectForLevel(level);
|
||||||
|
PotionEffect previous = appliedEffects.get(player.getUniqueId());
|
||||||
|
if (previous != null && !previous.equals(desired)) {
|
||||||
|
removeMatching(player, previous);
|
||||||
|
appliedEffects.remove(player.getUniqueId());
|
||||||
|
}
|
||||||
|
|
||||||
|
PotionEffect active = player.getPotionEffect(PotionEffectType.RESISTANCE);
|
||||||
|
if (desired.equals(active) && desired.equals(appliedEffects.get(player.getUniqueId()))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
boolean applied = player.addPotionEffect(desired);
|
||||||
|
if (applied) {
|
||||||
|
appliedEffects.put(player.getUniqueId(), desired);
|
||||||
|
}
|
||||||
|
return applied || desired.equals(active);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void remove(Player player) {
|
||||||
|
PotionEffect expected = appliedEffects.remove(player.getUniqueId());
|
||||||
|
if (expected != null) {
|
||||||
|
removeMatching(player, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean owns(Player player) {
|
||||||
|
PotionEffect expected = appliedEffects.get(player.getUniqueId());
|
||||||
|
return expected != null
|
||||||
|
&& expected.equals(player.getPotionEffect(PotionEffectType.RESISTANCE));
|
||||||
|
}
|
||||||
|
|
||||||
|
static PotionEffect effectForLevel(int level) {
|
||||||
|
if (level < 1 || level > 5) {
|
||||||
|
throw new IllegalArgumentException("Resistance level must be between 1 and 5");
|
||||||
|
}
|
||||||
|
return new PotionEffect(
|
||||||
|
PotionEffectType.RESISTANCE,
|
||||||
|
PotionEffect.INFINITE_DURATION,
|
||||||
|
level - 1,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void removeMatching(Player player, PotionEffect expected) {
|
||||||
|
PotionEffect active = player.getPotionEffect(PotionEffectType.RESISTANCE);
|
||||||
|
if (expected.equals(active)) {
|
||||||
|
player.removePotionEffect(PotionEffectType.RESISTANCE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
/** Coordinates durable player intent with live Leaf-managed protection. */
|
||||||
|
public final class LeafRuntime {
|
||||||
|
public enum Change {
|
||||||
|
CHANGED,
|
||||||
|
UNCHANGED,
|
||||||
|
LOCKED
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Status(
|
||||||
|
boolean savedChoice,
|
||||||
|
boolean activeProtection,
|
||||||
|
boolean locked,
|
||||||
|
boolean globallyEnabled,
|
||||||
|
Instant firstJoin
|
||||||
|
) { }
|
||||||
|
|
||||||
|
private final Server server;
|
||||||
|
private final LeafSettingsProvider settingsProvider;
|
||||||
|
private final LeafStateManager stateManager;
|
||||||
|
private final LeafProtection protection;
|
||||||
|
|
||||||
|
public LeafRuntime(
|
||||||
|
Server server,
|
||||||
|
LeafSettingsProvider settingsProvider,
|
||||||
|
LeafStateManager stateManager,
|
||||||
|
LeafProtection protection
|
||||||
|
) {
|
||||||
|
this.server = Objects.requireNonNull(server, "server");
|
||||||
|
this.settingsProvider = Objects.requireNonNull(settingsProvider, "settingsProvider");
|
||||||
|
this.stateManager = Objects.requireNonNull(stateManager, "stateManager");
|
||||||
|
this.protection = Objects.requireNonNull(protection, "protection");
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerLeafState observe(Player player, Instant observedAt) throws IOException {
|
||||||
|
PlayerLeafState state = stateManager.observePlayer(
|
||||||
|
player.getUniqueId(),
|
||||||
|
player.getName(),
|
||||||
|
observedAt
|
||||||
|
);
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
reconcile(player);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Change setOwnChoice(Player player, boolean optedIn) throws IOException {
|
||||||
|
PlayerLeafState state = requiredState(player.getUniqueId());
|
||||||
|
if (state.locked()) {
|
||||||
|
return Change.LOCKED;
|
||||||
|
}
|
||||||
|
return setChoice(player.getUniqueId(), optedIn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Change setChoice(UUID playerId, boolean optedIn) throws IOException {
|
||||||
|
PlayerLeafState state = requiredState(playerId);
|
||||||
|
if (state.optedIn() == optedIn) {
|
||||||
|
return Change.UNCHANGED;
|
||||||
|
}
|
||||||
|
stateManager.update(playerId, current -> current.withOptedIn(optedIn));
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
Player online = server.getPlayer(playerId);
|
||||||
|
if (online != null) {
|
||||||
|
reconcile(online);
|
||||||
|
}
|
||||||
|
return Change.CHANGED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Change setLocked(UUID playerId, boolean locked) throws IOException {
|
||||||
|
PlayerLeafState state = requiredState(playerId);
|
||||||
|
if (state.locked() == locked) {
|
||||||
|
return Change.UNCHANGED;
|
||||||
|
}
|
||||||
|
stateManager.update(playerId, current -> current.withLocked(locked));
|
||||||
|
stateManager.saveIfDirty();
|
||||||
|
return Change.CHANGED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Status status(UUID playerId) {
|
||||||
|
PlayerLeafState state = requiredState(playerId);
|
||||||
|
Player online = server.getPlayer(playerId);
|
||||||
|
boolean globallyEnabled = settingsProvider.current().enabled();
|
||||||
|
boolean active = online != null
|
||||||
|
&& globallyEnabled
|
||||||
|
&& state.optedIn()
|
||||||
|
&& protection.owns(online);
|
||||||
|
return new Status(
|
||||||
|
state.optedIn(),
|
||||||
|
active,
|
||||||
|
state.locked(),
|
||||||
|
globallyEnabled,
|
||||||
|
state.firstJoin()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
} else {
|
||||||
|
protection.remove(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeProtection(Player player) {
|
||||||
|
protection.remove(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LeafStateManager stateManager() {
|
||||||
|
return stateManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LeafSettings settings() {
|
||||||
|
return settingsProvider.current();
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlayerLeafState requiredState(UUID playerId) {
|
||||||
|
return stateManager.find(playerId).orElseThrow(
|
||||||
|
() -> new IllegalArgumentException("unknown player: " + playerId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package games.dmg.leaf;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.contains;
|
||||||
|
import static org.mockito.Mockito.atLeastOnce;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.command.Command;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
final class LeafCommandTest {
|
||||||
|
@Test
|
||||||
|
void playerCommandsReportChangesLocksAndDetailedStatus() throws Exception {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
when(player.getUniqueId()).thenReturn(id);
|
||||||
|
when(player.hasPermission("leaf.use")).thenReturn(true);
|
||||||
|
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||||
|
when(runtime.settings()).thenReturn(LeafSettings.from(java.util.Map.of()));
|
||||||
|
when(runtime.setOwnChoice(player, true)).thenReturn(LeafRuntime.Change.CHANGED);
|
||||||
|
when(runtime.setOwnChoice(player, false)).thenReturn(LeafRuntime.Change.LOCKED);
|
||||||
|
when(runtime.status(id)).thenReturn(new LeafRuntime.Status(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
Instant.parse("2026-08-10T00:00:00Z")
|
||||||
|
));
|
||||||
|
LeafCommand leaf = new LeafCommand(runtime);
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
|
||||||
|
assertTrue(leaf.onCommand(player, command, "leaf", new String[] {"on"}));
|
||||||
|
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("locked"));
|
||||||
|
verify(player, atLeastOnce()).sendMessage(contains("Saved choice: on"));
|
||||||
|
verify(player, atLeastOnce()).sendMessage(contains("active: yes"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void completionIncludesOnlyPlayerArgumentsAllowedToSender() {
|
||||||
|
LeafRuntime runtime = mock(LeafRuntime.class);
|
||||||
|
LeafCommand leaf = new LeafCommand(runtime);
|
||||||
|
Command command = mock(Command.class);
|
||||||
|
Player allowed = mock(Player.class);
|
||||||
|
when(allowed.hasPermission("leaf.use")).thenReturn(true);
|
||||||
|
Player denied = mock(Player.class);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
List.of("on", "off", "status"),
|
||||||
|
leaf.onTabComplete(allowed, command, "leaf", new String[] {""})
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
List.of(),
|
||||||
|
leaf.onTabComplete(denied, command, "leaf", new String[] {""})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import org.bukkit.Server;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
final class LeafRuntimeTest {
|
||||||
|
@TempDir
|
||||||
|
Path temporaryDirectory;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void playerCanOptInAndProtectionIsRestoredFromPersistedChoice() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = player(playerId, "Alex");
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
when(server.getPlayer(playerId)).thenReturn(player);
|
||||||
|
LeafProtection protection = mock(LeafProtection.class);
|
||||||
|
when(protection.apply(player, 1)).thenReturn(true);
|
||||||
|
Path stateFile = temporaryDirectory.resolve("state.yml");
|
||||||
|
LeafRuntime runtime = runtime(server, protection, stateFile);
|
||||||
|
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
|
||||||
|
|
||||||
|
assertEquals(LeafRuntime.Change.CHANGED, runtime.setOwnChoice(player, true));
|
||||||
|
verify(protection).apply(player, 1);
|
||||||
|
|
||||||
|
LeafRuntime restarted = runtime(server, protection, stateFile);
|
||||||
|
restarted.reconcile(player);
|
||||||
|
assertTrue(restarted.status(playerId).savedChoice());
|
||||||
|
verify(protection, org.mockito.Mockito.times(2)).apply(player, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void lockedPlayerCannotChangeTheirChoice() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = player(playerId, "Alex");
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
when(server.getPlayer(playerId)).thenReturn(player);
|
||||||
|
LeafProtection protection = mock(LeafProtection.class);
|
||||||
|
LeafRuntime runtime = runtime(
|
||||||
|
server,
|
||||||
|
protection,
|
||||||
|
temporaryDirectory.resolve("locked.yml")
|
||||||
|
);
|
||||||
|
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
|
||||||
|
runtime.setLocked(playerId, true);
|
||||||
|
|
||||||
|
assertEquals(LeafRuntime.Change.LOCKED, runtime.setOwnChoice(player, true));
|
||||||
|
assertFalse(runtime.status(playerId).savedChoice());
|
||||||
|
verify(protection, never()).apply(player, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void optingOutRemovesLeafProtectionAndRepeatingIsANoOp() throws Exception {
|
||||||
|
UUID playerId = UUID.randomUUID();
|
||||||
|
Player player = player(playerId, "Alex");
|
||||||
|
Server server = mock(Server.class);
|
||||||
|
when(server.getPlayer(playerId)).thenReturn(player);
|
||||||
|
LeafProtection protection = mock(LeafProtection.class);
|
||||||
|
LeafRuntime runtime = runtime(
|
||||||
|
server,
|
||||||
|
protection,
|
||||||
|
temporaryDirectory.resolve("off.yml")
|
||||||
|
);
|
||||||
|
runtime.observe(player, Instant.parse("2026-08-10T00:00:00Z"));
|
||||||
|
runtime.setOwnChoice(player, true);
|
||||||
|
|
||||||
|
assertEquals(LeafRuntime.Change.CHANGED, runtime.setOwnChoice(player, false));
|
||||||
|
assertEquals(LeafRuntime.Change.UNCHANGED, runtime.setOwnChoice(player, false));
|
||||||
|
verify(protection, org.mockito.Mockito.atLeastOnce()).remove(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LeafRuntime runtime(
|
||||||
|
Server server,
|
||||||
|
LeafProtection protection,
|
||||||
|
Path stateFile
|
||||||
|
) throws Exception {
|
||||||
|
return new LeafRuntime(
|
||||||
|
server,
|
||||||
|
new LeafSettingsProvider(LeafSettings.from(Map.of())),
|
||||||
|
new LeafStateManager(new YamlLeafStateRepository(stateFile)),
|
||||||
|
protection
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Player player(UUID id, String name) {
|
||||||
|
Player player = mock(Player.class);
|
||||||
|
when(player.getUniqueId()).thenReturn(id);
|
||||||
|
when(player.getName()).thenReturn(name);
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user