diff --git a/design/log.md b/design/log.md index 8e81e28..83bba0b 100644 --- a/design/log.md +++ b/design/log.md @@ -42,3 +42,10 @@ - Added Survival-and-axe eligibility, automatic-break suppression, durable one-point increments, saturating counters, permanent unlocks, and next-qualifying-block threshold evaluation. - Registered progress handling through the Spigot block-break lifecycle and persisted every accepted update before notifying observers. - Verified taxonomy, detection, tools, eligibility, progression, persistence, and the complete build with `./gradlew clean check jar`. + +### US-004 personal controls completed + +- Added `/treefeller enabled [on|off]` with durable, idempotent preference changes and administrative-override reporting. +- Added player-only usage and positional completion for `enabled`, `unlocked`, `undo`, and boolean values without exposing the administrative command tree. +- Registered configurable player messages and kept `treefeller.command` separate from `treefeller.admin`. +- Verified state preservation, reporting, autocomplete, metadata, and the complete build with `./gradlew clean check jar`. diff --git a/design/user-stories/us-004-control-personal-tree-felling.md b/design/user-stories/us-004-control-personal-tree-felling.md index 23c09fa..d3aad8a 100644 --- a/design/user-stories/us-004-control-personal-tree-felling.md +++ b/design/user-stories/us-004-control-personal-tree-felling.md @@ -2,7 +2,7 @@ type: User Story title: "US-004: Control personal tree felling" description: Let players persistently enable or disable their own automatic tree felling. -status: backlog +status: done --- # US-004: Control personal tree felling @@ -11,16 +11,16 @@ As a **player**, I want to turn automatic felling on or off independently of my ## Acceptance criteria -- [ ] `/treefeller enabled ` enables or disables automatic felling for the issuing player. -- [ ] `/treefeller enabled` without a value reports the player's current preference and whether an administrative lock currently overrides it. -- [ ] The preference defaults to enabled for a player with no saved value. -- [ ] Changing the preference does not alter species progress or earned unlocks. -- [ ] Repeating the currently saved value is idempotent and reports that no change was needed. -- [ ] The player receives clear confirmation after a successful change. -- [ ] The saved preference survives logout and server restart. -- [ ] `/treefeller` provides concise usage for `enabled`, `unlocked`, and `undo` without advertising inaccessible administrative commands. -- [ ] Position-aware autocomplete suggests player subcommands and valid `on` or `off` values. -- [ ] Player commands use player permissions that are distinct from `treefeller.admin`; possession of player permissions does not grant `/treefelleradmin` access. +- [x] `/treefeller enabled ` enables or disables automatic felling for the issuing player. +- [x] `/treefeller enabled` without a value reports the player's current preference and whether an administrative lock currently overrides it. +- [x] The preference defaults to enabled for a player with no saved value. +- [x] Changing the preference does not alter species progress or earned unlocks. +- [x] Repeating the currently saved value is idempotent and reports that no change was needed. +- [x] The player receives clear confirmation after a successful change. +- [x] The saved preference survives logout and server restart. +- [x] `/treefeller` provides concise usage for `enabled`, `unlocked`, and `undo` without advertising inaccessible administrative commands. +- [x] Position-aware autocomplete suggests player subcommands and valid `on` or `off` values. +- [x] Player commands use player permissions that are distinct from `treefeller.admin`; possession of player permissions does not grant `/treefelleradmin` access. ## Related diff --git a/src/main/java/games/dmg/treefeller/TreeFellerCommand.java b/src/main/java/games/dmg/treefeller/TreeFellerCommand.java new file mode 100644 index 0000000..f1cd205 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/TreeFellerCommand.java @@ -0,0 +1,131 @@ +package games.dmg.treefeller; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +/** Player-only `/treefeller` command tree. */ +public final class TreeFellerCommand implements CommandExecutor, TabCompleter { + private static final List SUBCOMMANDS = List.of("enabled", "undo", "unlocked"); + private static final List BOOLEAN_VALUES = List.of("off", "on"); + + private final PlayerStateStore states; + private final Consumer failureHandler; + private final Function messages; + + public TreeFellerCommand(PlayerStateStore states, Consumer failureHandler) { + this(states, failureHandler, TreeFellerCommand::defaultMessage); + } + + public TreeFellerCommand( + PlayerStateStore states, + Consumer failureHandler, + Function messages) { + this.states = states; + this.failureHandler = failureHandler; + this.messages = messages; + } + + @Override + public boolean onCommand( + CommandSender sender, Command command, String label, String[] arguments) { + if (!(sender instanceof Player player)) { + sender.sendMessage("Tree Feller player commands must be used in game."); + return true; + } + if (arguments.length == 0) { + sendUsage(player); + return true; + } + if (!arguments[0].equalsIgnoreCase("enabled")) { + sendUsage(player); + return true; + } + + PlayerTreeFellerState state = stateFor(player); + if (arguments.length == 1) { + player.sendMessage("Tree Feller is " + (state.enabled() ? "enabled" : "disabled") + "."); + if (state.locked()) { + player.sendMessage("An administrative lock currently overrides your preference."); + } + return true; + } + if (arguments.length != 2) { + player.sendMessage("Usage: /treefeller enabled "); + return true; + } + + Optional requested = parseBoolean(arguments[1]); + if (requested.isEmpty()) { + player.sendMessage("Usage: /treefeller enabled "); + return true; + } + boolean enabled = requested.orElseThrow(); + if (state.enabled() == enabled) { + player.sendMessage("Tree Feller is already " + (enabled ? "enabled" : "disabled") + "."); + return true; + } + try { + states.save(state.withEnabled(enabled)); + player.sendMessage(color(messages.apply(enabled ? "enabled" : "disabled"))); + } catch (IOException exception) { + failureHandler.accept(exception); + player.sendMessage("Tree Feller could not save your preference; no change was applied."); + } + return true; + } + + @Override + public List onTabComplete( + CommandSender sender, Command command, String alias, String[] arguments) { + if (arguments.length == 1) { + return matching(SUBCOMMANDS, arguments[0]); + } + if (arguments.length == 2 && arguments[0].equalsIgnoreCase("enabled")) { + return matching(BOOLEAN_VALUES, arguments[1]); + } + return List.of(); + } + + private PlayerTreeFellerState stateFor(Player player) { + return states.load(player.getUniqueId()) + .orElseGet(() -> PlayerTreeFellerState.initial( + player.getUniqueId(), player.getName())) + .observeName(player.getName()); + } + + private void sendUsage(Player player) { + player.sendMessage("Usage: /treefeller "); + } + + private static Optional parseBoolean(String value) { + if (value.equalsIgnoreCase("on")) { + return Optional.of(true); + } + if (value.equalsIgnoreCase("off")) { + return Optional.of(false); + } + return Optional.empty(); + } + + private static List matching(List values, String prefix) { + String normalized = prefix.toLowerCase(Locale.ROOT); + return values.stream().filter(value -> value.startsWith(normalized)).toList(); + } + + private static String defaultMessage(String key) { + return key.equals("enabled") ? "Tree Feller is enabled." : "Tree Feller is disabled."; + } + + private static String color(String value) { + return value.replace('&', '\u00a7'); + } +} diff --git a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java index 257f709..97cedb5 100644 --- a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java +++ b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java @@ -1,7 +1,9 @@ package games.dmg.treefeller; import java.nio.file.Path; +import java.util.Objects; import java.util.logging.Level; +import org.bukkit.command.PluginCommand; import org.bukkit.plugin.java.JavaPlugin; /** Entry point for Tree Feller. */ @@ -32,6 +34,16 @@ public final class TreeFellerPlugin extends JavaPlugin { exception -> getLogger().log( Level.SEVERE, "Unable to persist Tree Feller progress", exception)); getServer().getPluginManager().registerEvents(progressListener, this); + + TreeFellerCommand playerCommand = new TreeFellerCommand( + playerStateRepository, + exception -> getLogger().log( + Level.SEVERE, "Unable to persist Tree Feller preference", exception), + key -> settingsService.current().message(key)); + PluginCommand command = Objects.requireNonNull( + getCommand("treefeller"), "treefeller command missing from plugin.yml"); + command.setExecutor(playerCommand); + command.setTabCompleter(playerCommand); } catch (IllegalArgumentException exception) { getLogger().severe("Tree Feller configuration is invalid: " + exception.getMessage()); getServer().getPluginManager().disablePlugin(this); diff --git a/src/test/java/games/dmg/treefeller/TreeFellerCommandTest.java b/src/test/java/games/dmg/treefeller/TreeFellerCommandTest.java new file mode 100644 index 0000000..29cac85 --- /dev/null +++ b/src/test/java/games/dmg/treefeller/TreeFellerCommandTest.java @@ -0,0 +1,84 @@ +package games.dmg.treefeller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class TreeFellerCommandTest { + @Test + void persistentlyDisablesTheIssuingPlayerWithoutChangingUnlocks() throws Exception { + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getName()).thenReturn("Player"); + InMemoryStateStore states = new InMemoryStateStore(); + states.state = PlayerTreeFellerState.initial(playerId, "Player") + .withUnlocked(TreeSpecies.OAK, true); + TreeFellerCommand handler = new TreeFellerCommand(states, ignored -> { }); + + boolean handled = handler.onCommand( + player, mock(Command.class), "treefeller", new String[] {"enabled", "off"}); + + assertFalse(states.state.enabled()); + assertEquals(true, states.state.isUnlocked(TreeSpecies.OAK)); + assertEquals(1, states.saveCount); + verify(player).sendMessage(contains("disabled")); + assertEquals(true, handled); + } + + @Test + void reportsTheSavedPreferenceAndAdministrativeOverrideWithoutChangingState() { + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getName()).thenReturn("Player"); + InMemoryStateStore states = new InMemoryStateStore(); + states.state = PlayerTreeFellerState.initial(playerId, "Player").withLocked(true); + TreeFellerCommand handler = new TreeFellerCommand(states, ignored -> { }); + + handler.onCommand(player, mock(Command.class), "treefeller", new String[] {"enabled"}); + + verify(player).sendMessage(contains("enabled")); + verify(player).sendMessage(contains("administrative lock")); + assertEquals(0, states.saveCount); + } + + @Test + void completesOnlyPlayerCommandSyntaxByArgumentPosition() { + TreeFellerCommand handler = new TreeFellerCommand(new InMemoryStateStore(), ignored -> { }); + Command command = mock(Command.class); + + assertEquals(List.of("enabled"), handler.onTabComplete( + mock(Player.class), command, "treefeller", new String[] {"e"})); + assertEquals(List.of("off", "on"), handler.onTabComplete( + mock(Player.class), command, "treefeller", new String[] {"enabled", ""})); + assertEquals(List.of(), handler.onTabComplete( + mock(Player.class), command, "treefeller", new String[] {"admin", ""})); + } + + private static final class InMemoryStateStore implements PlayerStateStore { + private PlayerTreeFellerState state; + private int saveCount; + + @Override + public Optional load(UUID playerId) { + return Optional.ofNullable(state); + } + + @Override + public void save(PlayerTreeFellerState changed) { + state = changed; + saveCount++; + } + } +}