feat(protection): implement player opt-in lifecycle

This commit is contained in:
dmg
2026-08-10 18:19:03 -04:00
parent f805f9371d
commit 7deb79ad61
9 changed files with 556 additions and 10 deletions
+7
View File
@@ -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;
}
}