feat(command): add personal Tree Feller controls
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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 <on|off>` 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 <on|off>` 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
|
||||
|
||||
|
||||
@@ -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<String> SUBCOMMANDS = List.of("enabled", "undo", "unlocked");
|
||||
private static final List<String> BOOLEAN_VALUES = List.of("off", "on");
|
||||
|
||||
private final PlayerStateStore states;
|
||||
private final Consumer<Exception> failureHandler;
|
||||
private final Function<String, String> messages;
|
||||
|
||||
public TreeFellerCommand(PlayerStateStore states, Consumer<Exception> failureHandler) {
|
||||
this(states, failureHandler, TreeFellerCommand::defaultMessage);
|
||||
}
|
||||
|
||||
public TreeFellerCommand(
|
||||
PlayerStateStore states,
|
||||
Consumer<Exception> failureHandler,
|
||||
Function<String, String> 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 <on|off>");
|
||||
return true;
|
||||
}
|
||||
|
||||
Optional<Boolean> requested = parseBoolean(arguments[1]);
|
||||
if (requested.isEmpty()) {
|
||||
player.sendMessage("Usage: /treefeller enabled <on|off>");
|
||||
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<String> 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 <enabled|unlocked|undo>");
|
||||
}
|
||||
|
||||
private static Optional<Boolean> 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<String> matching(List<String> 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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<PlayerTreeFellerState> load(UUID playerId) {
|
||||
return Optional.ofNullable(state);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(PlayerTreeFellerState changed) {
|
||||
state = changed;
|
||||
saveCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user