From 7deb79ad617f6b9255ccf0a8dab813af9e5af4e1 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 10 Aug 2026 18:19:03 -0400 Subject: [PATCH] feat(protection): implement player opt-in lifecycle --- design/log.md | 7 + .../us-001-opt-into-leaf-protection.md | 20 +-- src/main/java/games/dmg/leaf/LeafCommand.java | 106 ++++++++++++++ .../java/games/dmg/leaf/LeafListener.java | 33 +++++ src/main/java/games/dmg/leaf/LeafPlugin.java | 33 +++++ .../java/games/dmg/leaf/LeafProtection.java | 66 +++++++++ src/main/java/games/dmg/leaf/LeafRuntime.java | 130 ++++++++++++++++++ .../java/games/dmg/leaf/LeafCommandTest.java | 67 +++++++++ .../java/games/dmg/leaf/LeafRuntimeTest.java | 104 ++++++++++++++ 9 files changed, 556 insertions(+), 10 deletions(-) create mode 100644 src/main/java/games/dmg/leaf/LeafCommand.java create mode 100644 src/main/java/games/dmg/leaf/LeafListener.java create mode 100644 src/main/java/games/dmg/leaf/LeafProtection.java create mode 100644 src/main/java/games/dmg/leaf/LeafRuntime.java create mode 100644 src/test/java/games/dmg/leaf/LeafCommandTest.java create mode 100644 src/test/java/games/dmg/leaf/LeafRuntimeTest.java diff --git a/design/log.md b/design/log.md index 30d9b99..f92b4bb 100644 --- a/design/log.md +++ b/design/log.md @@ -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`. diff --git a/design/user-stories/us-001-opt-into-leaf-protection.md b/design/user-stories/us-001-opt-into-leaf-protection.md index 74c9857..55726d5 100644 --- a/design/user-stories/us-001-opt-into-leaf-protection.md +++ b/design/user-stories/us-001-opt-into-leaf-protection.md @@ -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 diff --git a/src/main/java/games/dmg/leaf/LeafCommand.java b/src/main/java/games/dmg/leaf/LeafCommand.java new file mode 100644 index 0000000..d8a2a7d --- /dev/null +++ b/src/main/java/games/dmg/leaf/LeafCommand.java @@ -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 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 "); + 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 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 matching(List candidates, String partial) { + String normalized = partial.toLowerCase(Locale.ROOT); + List 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"; + } +} diff --git a/src/main/java/games/dmg/leaf/LeafListener.java b/src/main/java/games/dmg/leaf/LeafListener.java new file mode 100644 index 0000000..64e1d8e --- /dev/null +++ b/src/main/java/games/dmg/leaf/LeafListener.java @@ -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."); + } + } +} diff --git a/src/main/java/games/dmg/leaf/LeafPlugin.java b/src/main/java/games/dmg/leaf/LeafPlugin.java index 28c357e..af9518c 100644 --- a/src/main/java/games/dmg/leaf/LeafPlugin.java +++ b/src/main/java/games/dmg/leaf/LeafPlugin.java @@ -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; } diff --git a/src/main/java/games/dmg/leaf/LeafProtection.java b/src/main/java/games/dmg/leaf/LeafProtection.java new file mode 100644 index 0000000..917f3e3 --- /dev/null +++ b/src/main/java/games/dmg/leaf/LeafProtection.java @@ -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 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); + } + } +} diff --git a/src/main/java/games/dmg/leaf/LeafRuntime.java b/src/main/java/games/dmg/leaf/LeafRuntime.java new file mode 100644 index 0000000..940e8ad --- /dev/null +++ b/src/main/java/games/dmg/leaf/LeafRuntime.java @@ -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) + ); + } +} diff --git a/src/test/java/games/dmg/leaf/LeafCommandTest.java b/src/test/java/games/dmg/leaf/LeafCommandTest.java new file mode 100644 index 0000000..9213978 --- /dev/null +++ b/src/test/java/games/dmg/leaf/LeafCommandTest.java @@ -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[] {""}) + ); + } +} diff --git a/src/test/java/games/dmg/leaf/LeafRuntimeTest.java b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java new file mode 100644 index 0000000..969a7da --- /dev/null +++ b/src/test/java/games/dmg/leaf/LeafRuntimeTest.java @@ -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; + } +}