feat(commands): add toggle autocomplete aliases
Release / release (push) Successful in 2m17s
CI / build (push) Successful in 59s

This commit is contained in:
dmg
2026-08-10 20:07:07 -04:00
parent 00aa7566ad
commit 5c9dc5b361
16 changed files with 254 additions and 49 deletions
+3 -3
View File
@@ -25,9 +25,9 @@ The plugin JAR is written to `build/libs/`.
/setbase (alias: /sethome) /setbase (alias: /sethome)
/base (alias: /home) /base (alias: /home)
/base upgrade (alias: /home upgrade) /base upgrade (alias: /home upgrade)
/basenavigation /basenavigation [on|off] (alias: /homenavigation)
/baseflight /baseflight [on|off] (alias: /homeflight)
/basevisitors /basevisitors [on|off] (alias: /homevisitors)
/gotobase <player> (alias: /visit <player>) /gotobase <player> (alias: /visit <player>)
/baseprogress /baseprogress
/baseprogress bossbar /baseprogress bossbar
+7
View File
@@ -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`. - Added `/sethome` for `/setbase`, `/home` for `/base`, and `/visit` for `/gotobase`.
- Visitor autocomplete remains available for eligible offline bases through the `/visit` alias. - Visitor autocomplete remains available for eligible offline bases through the `/visit` alias.
- Verified the aliases and plugin build with `./gradlew clean check jar`. - 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`.
@@ -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` toggles guidance on and off after Base II is unlocked.
- [x] `/basenavigation on` enables guidance idempotently and `/basenavigation off` disables it idempotently. - [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] 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] Grass-or-dirt progress after Base II preserves the player's selected navigation preference.
- [x] The navigation preference persists across reconnects and restarts. - [x] The navigation preference persists across reconnects and restarts.
@@ -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] Passing beyond the warning buffer removes only flight granted by this plugin.
- [x] Flight is not granted outside the unlocked vertical range. - [x] Flight is not granted outside the unlocked vertical range.
- [x] `/baseflight` toggles the player's unlocked base flight on and off. - [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 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. - [x] The plugin handles teleportation, world changes, game-mode changes, death, logout, and plugin shutdown without leaving unintended flight enabled.
@@ -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] 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] 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` 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] The visitor-access preference persists across reconnects and restarts.
- [x] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit. - [x] `/gotobase <owner>` autocompletes bases that the requesting player is currently eligible to visit.
- [x] `/visit <owner>` aliases `/gotobase <owner>` with identical autocomplete, including eligible bases whose owners are offline. - [x] `/visit <owner>` aliases `/gotobase <owner>` with identical autocomplete, including eligible bases whose owners are offline.
@@ -1,12 +1,16 @@
package games.dmg.spigotbase; package games.dmg.spigotbase;
import java.util.List;
import java.util.Locale;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
final class BaseFlightCommand implements CommandExecutor { final class BaseFlightCommand implements CommandExecutor, TabCompleter {
private static final List<String> MODES = List.of("on", "off");
private final BaseStateManager stateManager; private final BaseStateManager stateManager;
private final BaseFlightController controller; private final BaseFlightController controller;
@@ -26,10 +30,17 @@ final class BaseFlightCommand implements CommandExecutor {
player.sendMessage(ChatColor.RED + "Base flight is still locked."); player.sendMessage(ChatColor.RED + "Base flight is still locked.");
return true; 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( state = stateManager.update(
player.getUniqueId(), player.getUniqueId(),
player.getName(), player.getName(),
current -> current.withFlightEnabled(!current.flightEnabled()) current -> current.withFlightEnabled(enabled)
); );
if (!state.flightEnabled()) { if (!state.flightEnabled()) {
controller.removeGrantedFlight(player); controller.removeGrantedFlight(player);
@@ -40,4 +51,18 @@ final class BaseFlightCommand implements CommandExecutor {
+ ChatColor.YELLOW + "."); + ChatColor.YELLOW + ".");
return true; return true;
} }
@Override
public List<String> 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();
}
} }
@@ -35,7 +35,7 @@ final class BaseNavigationCommand implements CommandExecutor, TabCompleter {
} }
final boolean enabled; final boolean enabled;
try { try {
enabled = NavigationPreference.resolve(state.navigationEnabled(), arguments); enabled = TogglePreference.resolve(state.navigationEnabled(), arguments);
} catch (IllegalArgumentException exception) { } catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED + "Usage: /basenavigation [on|off]"); player.sendMessage(ChatColor.RED + "Usage: /basenavigation [on|off]");
return true; return true;
@@ -1,12 +1,16 @@
package games.dmg.spigotbase; package games.dmg.spigotbase;
import java.util.List;
import java.util.Locale;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
final class BaseVisitorsCommand implements CommandExecutor { final class BaseVisitorsCommand implements CommandExecutor, TabCompleter {
private static final List<String> MODES = List.of("on", "off");
private final BaseStateManager stateManager; private final BaseStateManager stateManager;
BaseVisitorsCommand(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."); player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked.");
return true; 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( state = stateManager.update(
player.getUniqueId(), player.getUniqueId(),
player.getName(), player.getName(),
current -> current.withVisitorsEnabled(!current.visitorsEnabled()) current -> current.withVisitorsEnabled(enabled)
); );
stateManager.saveIfDirty(); stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now " player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now "
@@ -35,4 +46,18 @@ final class BaseVisitorsCommand implements CommandExecutor {
+ ChatColor.YELLOW + "."); + ChatColor.YELLOW + ".");
return true; return true;
} }
@Override
public List<String> 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();
}
} }
@@ -74,8 +74,12 @@ public final class SpigotBasePlugin extends JavaPlugin {
BaseNavigationCommand navigationCommand = new BaseNavigationCommand(stateManager); BaseNavigationCommand navigationCommand = new BaseNavigationCommand(stateManager);
command("basenavigation").setExecutor(navigationCommand); command("basenavigation").setExecutor(navigationCommand);
command("basenavigation").setTabCompleter(navigationCommand); command("basenavigation").setTabCompleter(navigationCommand);
command("baseflight").setExecutor(new BaseFlightCommand(stateManager, flightController)); BaseFlightCommand flightCommand = new BaseFlightCommand(stateManager, flightController);
command("basevisitors").setExecutor(new BaseVisitorsCommand(stateManager)); 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); GoToBaseCommand goToBaseCommand = new GoToBaseCommand(stateManager, teleportManager);
command("gotobase").setExecutor(goToBaseCommand); command("gotobase").setExecutor(goToBaseCommand);
command("gotobase").setTabCompleter(goToBaseCommand); command("gotobase").setTabCompleter(goToBaseCommand);
@@ -2,8 +2,8 @@ package games.dmg.spigotbase;
import java.util.Locale; import java.util.Locale;
final class NavigationPreference { final class TogglePreference {
private NavigationPreference() { private TogglePreference() {
} }
static boolean resolve(boolean current, String[] arguments) { static boolean resolve(boolean current, String[] arguments) {
+5 -2
View File
@@ -16,12 +16,15 @@ commands:
basenavigation: basenavigation:
description: Toggle particle navigation toward your base. description: Toggle particle navigation toward your base.
usage: /basenavigation [on|off] usage: /basenavigation [on|off]
aliases: [homenavigation]
baseflight: baseflight:
description: Toggle flight within your base. description: Toggle flight within your base.
usage: /baseflight usage: /baseflight [on|off]
aliases: [homeflight]
basevisitors: basevisitors:
description: Toggle visitor access to your base. description: Toggle visitor access to your base.
usage: /basevisitors usage: /basevisitors [on|off]
aliases: [homevisitors]
gotobase: gotobase:
description: Visit an available player base. description: Visit an available player base.
usage: /gotobase <player> usage: /gotobase <player>
@@ -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<PlayerState> 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<PlayerState> 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());
}
}
@@ -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<PlayerState> 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<PlayerState> 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());
}
}
@@ -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"})
);
}
}
@@ -26,6 +26,30 @@ final class PluginMetadataTest {
assertEquals(List.of("home"), base.get("aliases")); 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 @Test
void visitAliasesGotobase() { void visitAliasesGotobase() {
Map<?, ?> commands = commands(); Map<?, ?> commands = commands();
@@ -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"})
);
}
}