feat(command): add personal Tree Feller controls

This commit is contained in:
dmg
2026-08-11 17:20:40 -04:00
parent 8067b3ebfb
commit 486780c049
5 changed files with 245 additions and 11 deletions
+7
View File
@@ -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. - 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. - 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`. - 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 type: User Story
title: "US-004: Control personal tree felling" title: "US-004: Control personal tree felling"
description: Let players persistently enable or disable their own automatic tree felling. description: Let players persistently enable or disable their own automatic tree felling.
status: backlog status: done
--- ---
# US-004: Control personal tree felling # 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 ## Acceptance criteria
- [ ] `/treefeller enabled <on|off>` enables or disables automatic felling for the issuing player. - [x] `/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. - [x] `/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. - [x] The preference defaults to enabled for a player with no saved value.
- [ ] Changing the preference does not alter species progress or earned unlocks. - [x] 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. - [x] Repeating the currently saved value is idempotent and reports that no change was needed.
- [ ] The player receives clear confirmation after a successful change. - [x] The player receives clear confirmation after a successful change.
- [ ] The saved preference survives logout and server restart. - [x] The saved preference survives logout and server restart.
- [ ] `/treefeller` provides concise usage for `enabled`, `unlocked`, and `undo` without advertising inaccessible administrative commands. - [x] `/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. - [x] 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] Player commands use player permissions that are distinct from `treefeller.admin`; possession of player permissions does not grant `/treefelleradmin` access.
## Related ## 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; package games.dmg.treefeller;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Objects;
import java.util.logging.Level; import java.util.logging.Level;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
/** Entry point for Tree Feller. */ /** Entry point for Tree Feller. */
@@ -32,6 +34,16 @@ public final class TreeFellerPlugin extends JavaPlugin {
exception -> getLogger().log( exception -> getLogger().log(
Level.SEVERE, "Unable to persist Tree Feller progress", exception)); Level.SEVERE, "Unable to persist Tree Feller progress", exception));
getServer().getPluginManager().registerEvents(progressListener, this); 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) { } catch (IllegalArgumentException exception) {
getLogger().severe("Tree Feller configuration is invalid: " + exception.getMessage()); getLogger().severe("Tree Feller configuration is invalid: " + exception.getMessage());
getServer().getPluginManager().disablePlugin(this); 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++;
}
}
}