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 safe plugin initialization and periodic dirty-state persistence.
|
||||
- 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
|
||||
title: "US-001: Opt into Leaf protection"
|
||||
description: Let players voluntarily receive and relinquish a persistent Resistance boost.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] 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.
|
||||
- [ ] `/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.
|
||||
- [ ] 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.
|
||||
- [ ] Player-command autocomplete suggests only valid next arguments available to the sender.
|
||||
- [x] Players with the `leaf.use` permission, granted by default, can use `/leaf on`, `/leaf off`, and `/leaf status`.
|
||||
- [x] `/leaf on` records the player's opt-in choice and grants Resistance I while Leaf is globally enabled.
|
||||
- [x] Resistance remains continuously effective without distracting expiry or renewal messages or particles.
|
||||
- [x] `/leaf off` records the player's opt-out choice and immediately removes only the Resistance effect managed by Leaf.
|
||||
- [x] `/leaf status` clearly distinguishes the player's saved choice, active protection, administrative lock, and global Leaf state.
|
||||
- [x] Repeating an already-satisfied `on` or `off` command is safe and explains that no change was needed.
|
||||
- [x] Opt-in choices are keyed by UUID and survive logout and server restart.
|
||||
- [x] A player who joins while opted in regains protection when Leaf is enabled.
|
||||
- [x] Player-command autocomplete suggests only valid next arguments available to the sender.
|
||||
|
||||
## 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;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Level;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
public final class LeafPlugin extends JavaPlugin {
|
||||
private LeafSettingsProvider settingsProvider;
|
||||
private LeafStateManager stateManager;
|
||||
private LeafRuntime runtime;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -18,6 +22,13 @@ public final class LeafPlugin extends JavaPlugin {
|
||||
stateManager = new LeafStateManager(
|
||||
new YamlLeafStateRepository(getDataFolder().toPath().resolve("state.yml"))
|
||||
);
|
||||
runtime = new LeafRuntime(
|
||||
getServer(),
|
||||
settingsProvider,
|
||||
stateManager,
|
||||
new LeafProtection()
|
||||
);
|
||||
registerRuntime();
|
||||
} catch (IllegalArgumentException | IOException exception) {
|
||||
getLogger().log(Level.SEVERE, "Could not initialize Leaf", exception);
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
@@ -30,9 +41,31 @@ public final class LeafPlugin extends JavaPlugin {
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (runtime != null) {
|
||||
for (Player player : getServer().getOnlinePlayers()) {
|
||||
runtime.removeProtection(player);
|
||||
}
|
||||
}
|
||||
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() {
|
||||
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