2 Commits
Author SHA1 Message Date
dmg 00aa7566ad feat(commands): add base command aliases
Release / release (push) Successful in 2m21s
CI / build (push) Successful in 53s
2026-08-10 19:58:09 -04:00
dmg 84e013ac11 fix(navigation): preserve disabled preference
Release / release (push) Successful in 1m59s
CI / build (push) Successful in 48s
2026-08-09 15:38:40 -04:00
15 changed files with 162 additions and 10 deletions
+4 -4
View File
@@ -22,13 +22,13 @@ The plugin JAR is written to `build/libs/`.
## Player commands ## Player commands
```text ```text
/setbase /setbase (alias: /sethome)
/base /base (alias: /home)
/base upgrade /base upgrade (alias: /home upgrade)
/basenavigation /basenavigation
/baseflight /baseflight
/basevisitors /basevisitors
/gotobase <player> /gotobase <player> (alias: /visit <player>)
/baseprogress /baseprogress
/baseprogress bossbar /baseprogress bossbar
``` ```
+11
View File
@@ -28,6 +28,11 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Verified the implementation with `./gradlew clean check jar`: 34 tests passed and the plugin JAR was produced successfully. - Verified the implementation with `./gradlew clean check jar`: 34 tests passed and the plugin JAR was produced successfully.
- User stories remain in progress pending live-server integration verification and completion of runtime administrative configuration editing. - User stories remain in progress pending live-server integration verification and completion of runtime administrative configuration editing.
## 2026-08-09 — Navigation preference fix
- Added explicit, idempotent `/basenavigation on` and `/basenavigation off` modes with autocomplete while retaining no-argument toggling.
- Preserved a disabled navigation preference during later grass-or-dirt progression instead of forcing navigation back on.
## 2026-08-09 — Initial release scope completed ## 2026-08-09 — Initial release scope completed
- Added live, validated, persisted numeric configuration updates through `/baseadmin config`. - Added live, validated, persisted numeric configuration updates through `/baseadmin config`.
@@ -35,3 +40,9 @@ description: Chronological record of material decisions affecting the Spigot Bas
- Made progression materials, visitor currency, boss-bar duration, particle count, and title timing configurable. - Made progression materials, visitor currency, boss-bar duration, particle count, and title timing configurable.
- Preserved unknown forward-compatible YAML fields for retained player records while continuing to reject invalid progression state. - Preserved unknown forward-compatible YAML fields for retained player records while continuing to reject invalid progression state.
- Completed and verified all approved user stories with the automated Gradle check lifecycle. - Completed and verified all approved user stories with the automated Gradle check lifecycle.
## 2026-08-10 — Player command aliases
- 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`.
@@ -17,6 +17,7 @@ As a **player**, I want to earn and set a personal base so that later quality-of
- [x] Player-placed blocks may contribute when broken; natural-generation detection is not required. - [x] Player-placed blocks may contribute when broken; natural-generation detection is not required.
- [x] Base I unlocks when the player reaches the configured threshold, which defaults to 250 qualifying blocks. - [x] Base I unlocks when the player reaches the configured threshold, which defaults to 250 qualifying blocks.
- [x] `/setbase` is unavailable before Base I and explains the unmet requirement. - [x] `/setbase` is unavailable before Base I and explains the unmet requirement.
- [x] `/sethome` is an alias for `/setbase` with identical behavior.
- [x] After Base I unlocks, `/setbase` records the player's current world and block location as the center of a cylindrical base. - [x] After Base I unlocks, `/setbase` records the player's current world and block location as the center of a cylindrical base.
- [x] The initial cylinder has a configurable 10-block horizontal radius and extends a configurable 25 blocks above and 25 blocks below the set Y coordinate. - [x] The initial cylinder has a configurable 10-block horizontal radius and extends a configurable 25 blocks above and 25 blocks below the set Y coordinate.
- [x] The first successful `/setbase` is immediately available. - [x] The first successful `/setbase` is immediately available.
@@ -18,6 +18,9 @@ As a **player with Base I**, I want visual guidance toward my base so that I can
- [x] Particle generation is bounded to avoid excessive server or client load. - [x] Particle generation is bounded to avoid excessive server or client load.
- [x] A player in another world receives a clear message instead of a misleading particle direction. - [x] A player in another world receives a clear message instead of a misleading particle direction.
- [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] Invalid navigation arguments show command usage, and `on` and `off` are offered through 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. - [x] The navigation preference persists across reconnects and restarts.
## Related ## Related
@@ -15,6 +15,7 @@ As a **player with Base II**, I want to earn `/base` so that I can return safely
- [x] Only placements made in Survival mode and within the base's current horizontal and vertical bounds count. - [x] Only placements made in Survival mode and within the base's current horizontal and vertical bounds count.
- [x] Player-placed blocks and replacement of previously broken blocks may contribute repeatedly. - [x] Player-placed blocks and replacement of previously broken blocks may contribute repeatedly.
- [x] Base III unlocks `/base` with a configurable 30-second warm-up and three-hour cooldown by default. - [x] Base III unlocks `/base` with a configurable 30-second warm-up and three-hour cooldown by default.
- [x] `/home` is an alias for `/base`, including `/home upgrade`.
- [x] Looking around without changing block coordinates does not cancel the warm-up. - [x] Looking around without changing block coordinates does not cancel the warm-up.
- [x] Changing block X, Y, or Z, taking damage, teleporting, changing worlds, dying, disconnecting, or starting a conflicting teleport cancels the warm-up. - [x] Changing block X, Y, or Z, taking damage, teleporting, changing worlds, dying, disconnecting, or starting a conflicting teleport cancels the warm-up.
- [x] Cancellation clearly informs the player and does not consume the cooldown. - [x] Cancellation clearly informs the player and does not consume the cooldown.
@@ -18,6 +18,7 @@ As a **player with Base III**, I want to open my base to visitors so that other
- [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] 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] Enabled bases remain visitable while their owners are offline. - [x] Enabled bases remain visitable while their owners are offline.
- [x] A visitor teleport uses the destination owner's current warm-up tier. - [x] A visitor teleport uses the destination owner's current warm-up tier.
- [x] Looking around is permitted, while movement between block coordinates, damage, teleportation, world change, death, logout, or a conflicting teleport cancels the visitor warm-up. - [x] Looking around is permitted, while movement between block coordinates, damage, teleportation, world change, death, logout, or a conflicting teleport cancels the visitor warm-up.
@@ -1,12 +1,17 @@
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 BaseNavigationCommand implements CommandExecutor { final class BaseNavigationCommand implements CommandExecutor, TabCompleter {
private static final List<String> MODES = List.of("on", "off");
private final BaseStateManager stateManager; private final BaseStateManager stateManager;
BaseNavigationCommand(BaseStateManager stateManager) { BaseNavigationCommand(BaseStateManager stateManager) {
@@ -28,10 +33,17 @@ final class BaseNavigationCommand implements CommandExecutor {
player.sendMessage(ChatColor.RED + "Set your base before enabling navigation."); player.sendMessage(ChatColor.RED + "Set your base before enabling navigation.");
return true; return true;
} }
final boolean enabled;
try {
enabled = NavigationPreference.resolve(state.navigationEnabled(), arguments);
} catch (IllegalArgumentException exception) {
player.sendMessage(ChatColor.RED + "Usage: /basenavigation [on|off]");
return true;
}
state = stateManager.update( state = stateManager.update(
player.getUniqueId(), player.getUniqueId(),
player.getName(), player.getName(),
current -> current.withNavigationEnabled(!current.navigationEnabled()) current -> current.withNavigationEnabled(enabled)
); );
stateManager.saveIfDirty(); stateManager.saveIfDirty();
player.sendMessage(ChatColor.YELLOW + "Base navigation is now " player.sendMessage(ChatColor.YELLOW + "Base navigation is now "
@@ -44,4 +56,18 @@ final class BaseNavigationCommand implements CommandExecutor {
} }
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();
}
} }
@@ -25,7 +25,7 @@ public final class BaseProgressionService {
} }
boolean unlocked = baseLevel != previousLevel; boolean unlocked = baseLevel != previousLevel;
PlayerState updated = player.withGrassAndDirtProgress(count, baseLevel); PlayerState updated = player.withGrassAndDirtProgress(count, baseLevel);
if (baseLevel >= 2 && !updated.navigationEnabled()) { if (unlocked && baseLevel == 2) {
updated = updated.withNavigationEnabled(true); updated = updated.withNavigationEnabled(true);
} }
return new ProgressionUpdate(updated, unlocked, false, false, false, false); return new ProgressionUpdate(updated, unlocked, false, false, false, false);
@@ -189,7 +189,7 @@ final class BaseTeleportManager implements Listener {
public void onCommand(PlayerCommandPreprocessEvent event) { public void onCommand(PlayerCommandPreprocessEvent event) {
String command = event.getMessage().toLowerCase(Locale.ROOT).split("\\s+", 2)[0]; String command = event.getMessage().toLowerCase(Locale.ROOT).split("\\s+", 2)[0];
if (command.equals("/base") || command.equals("/gotobase") if (command.equals("/base") || command.equals("/gotobase")
|| command.equals("/spawn") || command.equals("/home") || command.equals("/visit") || command.equals("/spawn") || command.equals("/home")
|| command.equals("/tp") || command.equals("/teleport")) { || command.equals("/tp") || command.equals("/teleport")) {
cancel(event.getPlayer(), "Base teleport cancelled by another teleport command."); cancel(event.getPlayer(), "Base teleport cancelled by another teleport command.");
} }
@@ -0,0 +1,22 @@
package games.dmg.spigotbase;
import java.util.Locale;
final class NavigationPreference {
private NavigationPreference() {
}
static boolean resolve(boolean current, String[] arguments) {
if (arguments.length == 0) {
return !current;
}
if (arguments.length != 1) {
throw new IllegalArgumentException("expected zero or one argument");
}
return switch (arguments[0].toLowerCase(Locale.ROOT)) {
case "on" -> true;
case "off" -> false;
default -> throw new IllegalArgumentException("expected on or off");
};
}
}
@@ -71,7 +71,9 @@ public final class SpigotBasePlugin extends JavaPlugin {
command("baseprogress").setExecutor( command("baseprogress").setExecutor(
new BaseProgressCommand(stateManager, settingsProvider) new BaseProgressCommand(stateManager, settingsProvider)
); );
command("basenavigation").setExecutor(new BaseNavigationCommand(stateManager)); BaseNavigationCommand navigationCommand = new BaseNavigationCommand(stateManager);
command("basenavigation").setExecutor(navigationCommand);
command("basenavigation").setTabCompleter(navigationCommand);
command("baseflight").setExecutor(new BaseFlightCommand(stateManager, flightController)); command("baseflight").setExecutor(new BaseFlightCommand(stateManager, flightController));
command("basevisitors").setExecutor(new BaseVisitorsCommand(stateManager)); command("basevisitors").setExecutor(new BaseVisitorsCommand(stateManager));
GoToBaseCommand goToBaseCommand = new GoToBaseCommand(stateManager, teleportManager); GoToBaseCommand goToBaseCommand = new GoToBaseCommand(stateManager, teleportManager);
+4 -1
View File
@@ -8,12 +8,14 @@ commands:
setbase: setbase:
description: Set your unlocked personal base. description: Set your unlocked personal base.
usage: /setbase usage: /setbase
aliases: [sethome]
base: base:
description: Teleport to or upgrade your base. description: Teleport to or upgrade your base.
usage: /base [upgrade] usage: /base [upgrade]
aliases: [home]
basenavigation: basenavigation:
description: Toggle particle navigation toward your base. description: Toggle particle navigation toward your base.
usage: /basenavigation usage: /basenavigation [on|off]
baseflight: baseflight:
description: Toggle flight within your base. description: Toggle flight within your base.
usage: /baseflight usage: /baseflight
@@ -23,6 +25,7 @@ commands:
gotobase: gotobase:
description: Visit an available player base. description: Visit an available player base.
usage: /gotobase <player> usage: /gotobase <player>
aliases: [visit]
baseprogress: baseprogress:
description: View progression or toggle progress boss bars. description: View progression or toggle progress boss bars.
usage: /baseprogress [bossbar] usage: /baseprogress [bossbar]
@@ -1,6 +1,7 @@
package games.dmg.spigotbase; package games.dmg.spigotbase;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map; import java.util.Map;
@@ -21,4 +22,18 @@ final class BaseNavigationProgressionTest {
assertTrue(update.player().navigationEnabled()); assertTrue(update.player().navigationEnabled());
assertTrue(update.unlockedBaseLevel()); assertTrue(update.unlockedBaseLevel());
} }
@Test
void laterProgressPreservesDisabledNavigationPreference() {
BaseProgressionService service =
new BaseProgressionService(PluginSettings.from(Map.of()));
PlayerState player = PlayerState.newPlayer(UUID.randomUUID(), "Alex")
.withGrassAndDirtProgress(500, 2)
.withNavigationEnabled(false);
ProgressionUpdate update = service.recordGrassOrDirtBreak(player);
assertFalse(update.player().navigationEnabled());
assertFalse(update.unlockedBaseLevel());
}
} }
@@ -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 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"})
);
}
}
@@ -4,11 +4,36 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import java.io.InputStream; import java.io.InputStream;
import java.util.List;
import java.util.Map; import java.util.Map;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.Yaml;
final class PluginMetadataTest { final class PluginMetadataTest {
@Test
void sethomeAliasesSetbase() {
Map<?, ?> commands = commands();
Map<?, ?> setbase = (Map<?, ?>) commands.get("setbase");
assertEquals(List.of("sethome"), setbase.get("aliases"));
}
@Test
void homeAliasesBase() {
Map<?, ?> commands = commands();
Map<?, ?> base = (Map<?, ?>) commands.get("base");
assertEquals(List.of("home"), base.get("aliases"));
}
@Test
void visitAliasesGotobase() {
Map<?, ?> commands = commands();
Map<?, ?> gotobase = (Map<?, ?>) commands.get("gotobase");
assertEquals(List.of("visit"), gotobase.get("aliases"));
}
@Test @Test
void declaresPluginEntrypointCommandsAndPermissions() { void declaresPluginEntrypointCommandsAndPermissions() {
InputStream stream = getClass().getClassLoader().getResourceAsStream("plugin.yml"); InputStream stream = getClass().getClassLoader().getResourceAsStream("plugin.yml");
@@ -30,4 +55,11 @@ final class PluginMetadataTest {
Map<?, ?> permissions = (Map<?, ?>) plugin.get("permissions"); Map<?, ?> permissions = (Map<?, ?>) plugin.get("permissions");
assertNotNull(permissions.get("spigotbase.admin")); assertNotNull(permissions.get("spigotbase.admin"));
} }
private Map<?, ?> commands() {
InputStream stream = getClass().getClassLoader().getResourceAsStream("plugin.yml");
assertNotNull(stream);
Map<?, ?> plugin = new Yaml().load(stream);
return (Map<?, ?>) plugin.get("commands");
}
} }