diff --git a/README.md b/README.md index 35317f9..6754a93 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ The plugin JAR is written to `build/libs/`. /setbase (alias: /sethome) /base (alias: /home) /base upgrade (alias: /home upgrade) -/basenavigation -/baseflight -/basevisitors +/basenavigation [on|off] (alias: /homenavigation) +/baseflight [on|off] (alias: /homeflight) +/basevisitors [on|off] (alias: /homevisitors) /gotobase (alias: /visit ) /baseprogress /baseprogress bossbar diff --git a/design/log.md b/design/log.md index 756c51c..f2e02de 100644 --- a/design/log.md +++ b/design/log.md @@ -46,3 +46,10 @@ description: Chronological record of material decisions affecting the Spigot Bas - Added `/sethome` for `/setbase`, `/home` for `/base`, and `/visit` for `/gotobase`. - Visitor autocomplete remains available for eligible offline bases through the `/visit` alias. - Verified the aliases and plugin build with `./gradlew clean check jar`. + +## 2026-08-10 — Toggle command autocomplete + +- Added explicit, idempotent `on` and `off` modes and autocomplete to base flight and visitor access while retaining no-argument toggling. +- Added `/homenavigation`, `/homeflight`, and `/homevisitors` aliases with the same autocomplete as their canonical commands. +- Generalized toggle preference resolution for navigation, flight, and visitor access. +- Verified the implementation with `./gradlew clean check jar`. diff --git a/design/user-stories/us-002-unlock-base-navigation.md b/design/user-stories/us-002-unlock-base-navigation.md index e51a7dc..6ff76be 100644 --- a/design/user-stories/us-002-unlock-base-navigation.md +++ b/design/user-stories/us-002-unlock-base-navigation.md @@ -20,6 +20,7 @@ As a **player with Base I**, I want visual guidance toward my base so that I can - [x] `/basenavigation` toggles guidance on and off after Base II is unlocked. - [x] `/basenavigation on` enables guidance idempotently and `/basenavigation off` disables it idempotently. - [x] Invalid navigation arguments show command usage, and `on` and `off` are offered through autocomplete. +- [x] `/homenavigation` aliases `/basenavigation` with identical behavior and autocomplete. - [x] Grass-or-dirt progress after Base II preserves the player's selected navigation preference. - [x] The navigation preference persists across reconnects and restarts. diff --git a/design/user-stories/us-004-unlock-and-control-base-flight.md b/design/user-stories/us-004-unlock-and-control-base-flight.md index 723c6e1..225c01b 100644 --- a/design/user-stories/us-004-unlock-and-control-base-flight.md +++ b/design/user-stories/us-004-unlock-and-control-base-flight.md @@ -25,6 +25,8 @@ As a **player with Base I**, I want to unlock controlled flight around my base s - [x] Passing beyond the warning buffer removes only flight granted by this plugin. - [x] Flight is not granted outside the unlocked vertical range. - [x] `/baseflight` toggles the player's unlocked base flight on and off. +- [x] `/baseflight on` enables flight idempotently and `/baseflight off` disables it idempotently, with `on` and `off` offered through autocomplete. +- [x] `/homeflight` aliases `/baseflight` with identical behavior and autocomplete. - [x] The flight toggle persists across reconnects and restarts. - [x] The plugin handles teleportation, world changes, game-mode changes, death, logout, and plugin shutdown without leaving unintended flight enabled. diff --git a/design/user-stories/us-008-unlock-visitor-access.md b/design/user-stories/us-008-unlock-visitor-access.md index d057c04..e821a84 100644 --- a/design/user-stories/us-008-unlock-visitor-access.md +++ b/design/user-stories/us-008-unlock-visitor-access.md @@ -16,6 +16,8 @@ As a **player with Base III**, I want to open my base to visitors so that other - [x] A successful purchase removes the complete price atomically from the player's direct inventory. - [x] Insufficient funds, an invalid state, or a failed persistence operation does not consume any diamonds or grant Base IV. - [x] `/basevisitors` lets a Base IV owner toggle visitor access on and off. +- [x] `/basevisitors on` enables access idempotently and `/basevisitors off` disables it idempotently, with `on` and `off` offered through autocomplete. +- [x] `/homevisitors` aliases `/basevisitors` with identical behavior and autocomplete. - [x] The visitor-access preference persists across reconnects and restarts. - [x] `/gotobase ` autocompletes bases that the requesting player is currently eligible to visit. - [x] `/visit ` aliases `/gotobase ` with identical autocomplete, including eligible bases whose owners are offline. diff --git a/src/main/java/games/dmg/spigotbase/BaseFlightCommand.java b/src/main/java/games/dmg/spigotbase/BaseFlightCommand.java index b9e9345..9795247 100644 --- a/src/main/java/games/dmg/spigotbase/BaseFlightCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseFlightCommand.java @@ -1,12 +1,16 @@ package games.dmg.spigotbase; +import java.util.List; +import java.util.Locale; import org.bukkit.ChatColor; 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; -final class BaseFlightCommand implements CommandExecutor { +final class BaseFlightCommand implements CommandExecutor, TabCompleter { + private static final List MODES = List.of("on", "off"); private final BaseStateManager stateManager; private final BaseFlightController controller; @@ -26,10 +30,17 @@ final class BaseFlightCommand implements CommandExecutor { player.sendMessage(ChatColor.RED + "Base flight is still locked."); return true; } + final boolean enabled; + try { + enabled = TogglePreference.resolve(state.flightEnabled(), arguments); + } catch (IllegalArgumentException exception) { + player.sendMessage(ChatColor.RED + "Usage: /baseflight [on|off]"); + return true; + } state = stateManager.update( player.getUniqueId(), player.getName(), - current -> current.withFlightEnabled(!current.flightEnabled()) + current -> current.withFlightEnabled(enabled) ); if (!state.flightEnabled()) { controller.removeGrantedFlight(player); @@ -40,4 +51,18 @@ final class BaseFlightCommand implements CommandExecutor { + ChatColor.YELLOW + "."); return true; } + + @Override + public List onTabComplete( + CommandSender sender, + Command command, + String alias, + String[] arguments + ) { + if (arguments.length != 1) { + return List.of(); + } + String prefix = arguments[0].toLowerCase(Locale.ROOT); + return MODES.stream().filter(mode -> mode.startsWith(prefix)).toList(); + } } diff --git a/src/main/java/games/dmg/spigotbase/BaseNavigationCommand.java b/src/main/java/games/dmg/spigotbase/BaseNavigationCommand.java index 556a89c..1200aec 100644 --- a/src/main/java/games/dmg/spigotbase/BaseNavigationCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseNavigationCommand.java @@ -35,7 +35,7 @@ final class BaseNavigationCommand implements CommandExecutor, TabCompleter { } final boolean enabled; try { - enabled = NavigationPreference.resolve(state.navigationEnabled(), arguments); + enabled = TogglePreference.resolve(state.navigationEnabled(), arguments); } catch (IllegalArgumentException exception) { player.sendMessage(ChatColor.RED + "Usage: /basenavigation [on|off]"); return true; diff --git a/src/main/java/games/dmg/spigotbase/BaseVisitorsCommand.java b/src/main/java/games/dmg/spigotbase/BaseVisitorsCommand.java index 09765f1..758a539 100644 --- a/src/main/java/games/dmg/spigotbase/BaseVisitorsCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseVisitorsCommand.java @@ -1,12 +1,16 @@ package games.dmg.spigotbase; +import java.util.List; +import java.util.Locale; import org.bukkit.ChatColor; 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; -final class BaseVisitorsCommand implements CommandExecutor { +final class BaseVisitorsCommand implements CommandExecutor, TabCompleter { + private static final List MODES = List.of("on", "off"); private final BaseStateManager stateManager; BaseVisitorsCommand(BaseStateManager stateManager) { @@ -24,10 +28,17 @@ final class BaseVisitorsCommand implements CommandExecutor { player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked."); return true; } + final boolean enabled; + try { + enabled = TogglePreference.resolve(state.visitorsEnabled(), arguments); + } catch (IllegalArgumentException exception) { + player.sendMessage(ChatColor.RED + "Usage: /basevisitors [on|off]"); + return true; + } state = stateManager.update( player.getUniqueId(), player.getName(), - current -> current.withVisitorsEnabled(!current.visitorsEnabled()) + current -> current.withVisitorsEnabled(enabled) ); stateManager.saveIfDirty(); player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now " @@ -35,4 +46,18 @@ final class BaseVisitorsCommand implements CommandExecutor { + ChatColor.YELLOW + "."); return true; } + + @Override + public List onTabComplete( + CommandSender sender, + Command command, + String alias, + String[] arguments + ) { + if (arguments.length != 1) { + return List.of(); + } + String prefix = arguments[0].toLowerCase(Locale.ROOT); + return MODES.stream().filter(mode -> mode.startsWith(prefix)).toList(); + } } diff --git a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java index cd31cfa..8092fc3 100644 --- a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java +++ b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java @@ -74,8 +74,12 @@ public final class SpigotBasePlugin extends JavaPlugin { BaseNavigationCommand navigationCommand = new BaseNavigationCommand(stateManager); command("basenavigation").setExecutor(navigationCommand); command("basenavigation").setTabCompleter(navigationCommand); - command("baseflight").setExecutor(new BaseFlightCommand(stateManager, flightController)); - command("basevisitors").setExecutor(new BaseVisitorsCommand(stateManager)); + BaseFlightCommand flightCommand = new BaseFlightCommand(stateManager, flightController); + command("baseflight").setExecutor(flightCommand); + command("baseflight").setTabCompleter(flightCommand); + BaseVisitorsCommand visitorsCommand = new BaseVisitorsCommand(stateManager); + command("basevisitors").setExecutor(visitorsCommand); + command("basevisitors").setTabCompleter(visitorsCommand); GoToBaseCommand goToBaseCommand = new GoToBaseCommand(stateManager, teleportManager); command("gotobase").setExecutor(goToBaseCommand); command("gotobase").setTabCompleter(goToBaseCommand); diff --git a/src/main/java/games/dmg/spigotbase/NavigationPreference.java b/src/main/java/games/dmg/spigotbase/TogglePreference.java similarity index 88% rename from src/main/java/games/dmg/spigotbase/NavigationPreference.java rename to src/main/java/games/dmg/spigotbase/TogglePreference.java index ad732a5..9a2c8e4 100644 --- a/src/main/java/games/dmg/spigotbase/NavigationPreference.java +++ b/src/main/java/games/dmg/spigotbase/TogglePreference.java @@ -2,8 +2,8 @@ package games.dmg.spigotbase; import java.util.Locale; -final class NavigationPreference { - private NavigationPreference() { +final class TogglePreference { + private TogglePreference() { } static boolean resolve(boolean current, String[] arguments) { diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index e61ee53..d1431e3 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -16,12 +16,15 @@ commands: basenavigation: description: Toggle particle navigation toward your base. usage: /basenavigation [on|off] + aliases: [homenavigation] baseflight: description: Toggle flight within your base. - usage: /baseflight + usage: /baseflight [on|off] + aliases: [homeflight] basevisitors: description: Toggle visitor access to your base. - usage: /basevisitors + usage: /basevisitors [on|off] + aliases: [homevisitors] gotobase: description: Visit an available player base. usage: /gotobase diff --git a/src/test/java/games/dmg/spigotbase/BaseFlightCommandTest.java b/src/test/java/games/dmg/spigotbase/BaseFlightCommandTest.java new file mode 100644 index 0000000..ee1cd8b --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseFlightCommandTest.java @@ -0,0 +1,59 @@ +package games.dmg.spigotbase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.UnaryOperator; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +final class BaseFlightCommandTest { + @Test + void autocompletesExplicitModes() { + BaseFlightCommand command = new BaseFlightCommand( + mock(BaseStateManager.class), + mock(BaseFlightController.class) + ); + + assertEquals( + List.of("off"), + command.onTabComplete(null, null, "baseflight", new String[] {"of"}) + ); + } + + @Test + void explicitOnIsIdempotent() { + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getName()).thenReturn("Builder"); + + PlayerState current = PlayerState.newPlayer(playerId, "Builder") + .withAdministrativeLevels(1, 0, 1, 0, 0, false, true, false); + AtomicReference updated = new AtomicReference<>(); + BaseStateManager stateManager = mock(BaseStateManager.class); + when(stateManager.player(playerId, "Builder")).thenReturn(current); + when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + UnaryOperator operation = invocation.getArgument(2); + PlayerState result = operation.apply(current); + updated.set(result); + return result; + }); + + BaseFlightCommand command = new BaseFlightCommand( + stateManager, + mock(BaseFlightController.class) + ); + command.onCommand(player, null, "baseflight", new String[] {"on"}); + + assertTrue(updated.get().flightEnabled()); + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseVisitorsCommandTest.java b/src/test/java/games/dmg/spigotbase/BaseVisitorsCommandTest.java new file mode 100644 index 0000000..08b2118 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseVisitorsCommandTest.java @@ -0,0 +1,53 @@ +package games.dmg.spigotbase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.UnaryOperator; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +final class BaseVisitorsCommandTest { + @Test + void autocompletesExplicitModes() { + BaseVisitorsCommand command = new BaseVisitorsCommand(mock(BaseStateManager.class)); + + assertEquals( + List.of("on"), + command.onTabComplete(null, null, "basevisitors", new String[] {"on"}) + ); + } + + @Test + void explicitOnIsIdempotent() { + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getName()).thenReturn("Host"); + + PlayerState current = PlayerState.newPlayer(playerId, "Host") + .withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true); + AtomicReference updated = new AtomicReference<>(); + BaseStateManager stateManager = mock(BaseStateManager.class); + when(stateManager.player(playerId, "Host")).thenReturn(current); + when(stateManager.update(any(), anyString(), any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + UnaryOperator operation = invocation.getArgument(2); + PlayerState result = operation.apply(current); + updated.set(result); + return result; + }); + + BaseVisitorsCommand command = new BaseVisitorsCommand(stateManager); + command.onCommand(player, null, "basevisitors", new String[] {"on"}); + + assertTrue(updated.get().visitorsEnabled()); + } +} diff --git a/src/test/java/games/dmg/spigotbase/NavigationPreferenceTest.java b/src/test/java/games/dmg/spigotbase/NavigationPreferenceTest.java deleted file mode 100644 index c55d249..0000000 --- a/src/test/java/games/dmg/spigotbase/NavigationPreferenceTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package games.dmg.spigotbase; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import org.junit.jupiter.api.Test; - -final class NavigationPreferenceTest { - @Test - void noArgumentTogglesCurrentPreference() { - assertFalse(NavigationPreference.resolve(true, new String[0])); - assertTrue(NavigationPreference.resolve(false, new String[0])); - } - - @Test - void explicitModesAreIdempotent() { - assertTrue(NavigationPreference.resolve(true, new String[] {"on"})); - assertTrue(NavigationPreference.resolve(false, new String[] {"on"})); - assertFalse(NavigationPreference.resolve(true, new String[] {"off"})); - assertFalse(NavigationPreference.resolve(false, new String[] {"off"})); - } - - @Test - void rejectsUnknownOrExtraArguments() { - assertThrows( - IllegalArgumentException.class, - () -> NavigationPreference.resolve(false, new String[] {"maybe"}) - ); - assertThrows( - IllegalArgumentException.class, - () -> NavigationPreference.resolve(false, new String[] {"on", "off"}) - ); - } -} diff --git a/src/test/java/games/dmg/spigotbase/PluginMetadataTest.java b/src/test/java/games/dmg/spigotbase/PluginMetadataTest.java index 01bb403..1bac390 100644 --- a/src/test/java/games/dmg/spigotbase/PluginMetadataTest.java +++ b/src/test/java/games/dmg/spigotbase/PluginMetadataTest.java @@ -26,6 +26,30 @@ final class PluginMetadataTest { assertEquals(List.of("home"), base.get("aliases")); } + @Test + void homevisitorsAliasesBasevisitors() { + Map commands = commands(); + + Map visitors = (Map) commands.get("basevisitors"); + assertEquals(List.of("homevisitors"), visitors.get("aliases")); + } + + @Test + void homeflightAliasesBaseflight() { + Map commands = commands(); + + Map flight = (Map) commands.get("baseflight"); + assertEquals(List.of("homeflight"), flight.get("aliases")); + } + + @Test + void homenavigationAliasesBasenavigation() { + Map commands = commands(); + + Map navigation = (Map) commands.get("basenavigation"); + assertEquals(List.of("homenavigation"), navigation.get("aliases")); + } + @Test void visitAliasesGotobase() { Map commands = commands(); diff --git a/src/test/java/games/dmg/spigotbase/TogglePreferenceTest.java b/src/test/java/games/dmg/spigotbase/TogglePreferenceTest.java new file mode 100644 index 0000000..289696f --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/TogglePreferenceTest.java @@ -0,0 +1,35 @@ +package games.dmg.spigotbase; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +final class TogglePreferenceTest { + @Test + void noArgumentTogglesCurrentPreference() { + assertFalse(TogglePreference.resolve(true, new String[0])); + assertTrue(TogglePreference.resolve(false, new String[0])); + } + + @Test + void explicitModesAreIdempotent() { + assertTrue(TogglePreference.resolve(true, new String[] {"on"})); + assertTrue(TogglePreference.resolve(false, new String[] {"on"})); + assertFalse(TogglePreference.resolve(true, new String[] {"off"})); + assertFalse(TogglePreference.resolve(false, new String[] {"off"})); + } + + @Test + void rejectsUnknownOrExtraArguments() { + assertThrows( + IllegalArgumentException.class, + () -> TogglePreference.resolve(false, new String[] {"maybe"}) + ); + assertThrows( + IllegalArgumentException.class, + () -> TogglePreference.resolve(false, new String[] {"on", "off"}) + ); + } +}