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.
|
- 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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user