From 4278e08bf3f11cfd505eaf09d4fc564d2ac1a465 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 10 Aug 2026 20:44:34 -0400 Subject: [PATCH] feat(commands): unify settings and admin commands --- README.md | 19 +- design/log.md | 16 + .../us-002-unlock-base-navigation.md | 6 +- .../us-004-unlock-and-control-base-flight.md | 5 +- .../us-005-unlock-base-teleportation.md | 2 +- .../us-008-unlock-visitor-access.md | 7 +- ...-009-view-progression-and-notifications.md | 9 +- .../us-010-administer-player-progression.md | 8 +- .../dmg/spigotbase/BaseAdminCommand.java | 153 ++++++- .../games/dmg/spigotbase/BaseCommand.java | 107 +---- .../dmg/spigotbase/BaseFlightCommand.java | 68 ---- .../dmg/spigotbase/BaseFlightController.java | 2 +- .../dmg/spigotbase/BaseNavigationCommand.java | 73 ---- .../dmg/spigotbase/BaseProgressCommand.java | 121 ------ .../dmg/spigotbase/BaseProgressListener.java | 2 +- .../dmg/spigotbase/BaseSettingsCommand.java | 377 ++++++++++++++++++ .../dmg/spigotbase/BaseVisitorsCommand.java | 63 --- .../dmg/spigotbase/SpigotBasePlugin.java | 35 +- .../dmg/spigotbase/TogglePreference.java | 22 - src/main/resources/plugin.yml | 24 +- .../dmg/spigotbase/BaseAdminCommandTest.java | 227 +++++++++++ .../dmg/spigotbase/BaseFlightCommandTest.java | 59 --- .../spigotbase/BaseSettingsCommandTest.java | 230 +++++++++++ .../spigotbase/BaseVisitorsCommandTest.java | 53 --- .../dmg/spigotbase/PluginMetadataTest.java | 56 +-- .../dmg/spigotbase/TogglePreferenceTest.java | 35 -- 26 files changed, 1080 insertions(+), 699 deletions(-) delete mode 100644 src/main/java/games/dmg/spigotbase/BaseFlightCommand.java delete mode 100644 src/main/java/games/dmg/spigotbase/BaseNavigationCommand.java delete mode 100644 src/main/java/games/dmg/spigotbase/BaseProgressCommand.java create mode 100644 src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java delete mode 100644 src/main/java/games/dmg/spigotbase/BaseVisitorsCommand.java delete mode 100644 src/main/java/games/dmg/spigotbase/TogglePreference.java create mode 100644 src/test/java/games/dmg/spigotbase/BaseAdminCommandTest.java delete mode 100644 src/test/java/games/dmg/spigotbase/BaseFlightCommandTest.java create mode 100644 src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java delete mode 100644 src/test/java/games/dmg/spigotbase/BaseVisitorsCommandTest.java delete mode 100644 src/test/java/games/dmg/spigotbase/TogglePreferenceTest.java diff --git a/README.md b/README.md index 6754a93..05255e4 100644 --- a/README.md +++ b/README.md @@ -24,28 +24,29 @@ The plugin JAR is written to `build/libs/`. ```text /setbase (alias: /sethome) /base (alias: /home) -/base upgrade (alias: /home upgrade) -/basenavigation [on|off] (alias: /homenavigation) -/baseflight [on|off] (alias: /homeflight) -/basevisitors [on|off] (alias: /homevisitors) /gotobase (alias: /visit ) -/baseprogress -/baseprogress bossbar +/basesettings (alias: /homesettings) +/basesettings status +/basesettings upgrade +/basesettings visitors +/basesettings navigation +/basesettings flight +/basesettings bossbar ``` `/base` has a stationary warm-up. Looking around is allowed, while movement between blocks, damage, teleportation, world changes, death, logout, and conflicting teleport commands cancel it without consuming the cooldown. ## Administration -The `spigotbase.admin` permission is granted to server operators by default. +The `spigotbase.admin` permission is granted to server operators by default. `/homeadmin` aliases `/baseadmin`. ```text -/baseadmin progress +/baseadmin status /baseadmin setlevel /baseadmin setprogress /baseadmin config /baseadmin clearcooldown [personal|visitor|all] -/baseadmin reset +/baseadmin reset /baseadmin reset all confirm ``` diff --git a/design/log.md b/design/log.md index f2e02de..91a40ba 100644 --- a/design/log.md +++ b/design/log.md @@ -53,3 +53,19 @@ description: Chronological record of material decisions affecting the Spigot Bas - 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`. + +## 2026-08-10 — Unified base settings command + +- Replaced the separate progress, navigation, flight, and visitor commands with `/basesettings` and its `/homesettings` alias. +- Added nested autocomplete for status, Base IV upgrades, visitor access, navigation, flight, and progress boss-bar settings. +- Moved the Base IV purchase from `/base upgrade` to `/basesettings upgrade`, leaving `/base` and `/home` dedicated to personal teleportation. +- Preserved the full progression report as the default `/basesettings` response and as `/basesettings status`. +- Verified the breaking command restructure with `./gradlew clean check jar`. + +## 2026-08-10 — Administrative command autocomplete and reset safety + +- Added `/homeadmin` as an alias for `/baseadmin` and replaced the administrative `progress` subcommand with `status`. +- Added permission-aware, context-sensitive autocomplete for subcommands, online and known offline players, paths, levels, counters, cooldown types, reset confirmation, and numeric configuration keys. +- Removed the unsafe unconfirmed base reset path; complete resets now require `reset all confirm`. +- Tightened argument validation and replaced implementation-specific enum errors with player-facing usage guidance. +- Verified the administrative revision 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 6ff76be..ac3e325 100644 --- a/design/user-stories/us-002-unlock-base-navigation.md +++ b/design/user-stories/us-002-unlock-base-navigation.md @@ -17,10 +17,8 @@ As a **player with Base I**, I want visual guidance toward my base so that I can - [x] While enabled and in the base's world, particles are drawn along the ground to indicate the direction toward the base. - [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] `/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] `/basesettings navigation enable` enables guidance idempotently after Base II is unlocked, and `/basesettings navigation disable` disables it idempotently. +- [x] Invalid navigation arguments show command usage, and `enable` and `disable` 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. 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 225c01b..38ceb97 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 @@ -24,9 +24,8 @@ As a **player with Base I**, I want to unlock controlled flight around my base s - [x] Plugin-granted flight remains active in the warning buffer and displays prominent on-screen notice that the player is leaving the base. - [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] `/basesettings flight enable` enables unlocked flight idempotently, and `/basesettings flight disable` disables it idempotently. +- [x] Invalid flight arguments show command usage, and `enable` and `disable` are offered through 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-005-unlock-base-teleportation.md b/design/user-stories/us-005-unlock-base-teleportation.md index 75274e6..ac086f1 100644 --- a/design/user-stories/us-005-unlock-base-teleportation.md +++ b/design/user-stories/us-005-unlock-base-teleportation.md @@ -15,7 +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] 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] `/home` is an alias for `/base`, including `/home upgrade`. +- [x] `/home` aliases the teleport-only `/base` command. - [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] Cancellation clearly informs the player and does not consume the cooldown. diff --git a/design/user-stories/us-008-unlock-visitor-access.md b/design/user-stories/us-008-unlock-visitor-access.md index e821a84..e3bd5d8 100644 --- a/design/user-stories/us-008-unlock-visitor-access.md +++ b/design/user-stories/us-008-unlock-visitor-access.md @@ -12,12 +12,11 @@ As a **player with Base III**, I want to open my base to visitors so that other ## Acceptance criteria - [x] Base IV requires Base III and an established base. -- [x] `/base upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds. +- [x] `/basesettings upgrade` offers the Base IV purchase for a configurable price that defaults to 128 diamonds, replacing `/base upgrade` and `/home upgrade`. - [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] `/basesettings visitors allowed` permits visitor access idempotently for a Base IV owner, and `/basesettings visitors blocked` blocks it idempotently. +- [x] Invalid visitor arguments show command usage, and `allowed` and `blocked` are offered through 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/design/user-stories/us-009-view-progression-and-notifications.md b/design/user-stories/us-009-view-progression-and-notifications.md index a3a764d..3738ec8 100644 --- a/design/user-stories/us-009-view-progression-and-notifications.md +++ b/design/user-stories/us-009-view-progression-and-notifications.md @@ -11,17 +11,20 @@ As a **player**, I want to inspect my progression and receive timely milestone f ## Acceptance criteria -- [x] `/baseprogress` shows the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths. +- [x] `/basesettings` and `/basesettings status` show the player's Base, Base Size, Base Flight, Teleport Warm-up, and Teleport Cooldown paths plus current visitor, navigation, flight, and boss-bar settings. - [x] Each path identifies earned levels, unmet prerequisites, current progress, the next threshold, and the next reward. - [x] Locked secondary paths clearly identify Base I or another sequential level as their prerequisite. - [x] Relevant qualifying activity briefly displays a configurable progress boss bar for the active milestone. - [x] Boss-bar text and fill accurately represent the current count and threshold and never exceed 100 percent. - [x] The automatic boss bar disappears after a configurable number of seconds. -- [x] `/baseprogress bossbar` toggles automatic progress boss bars on and off. -- [x] Disabling automatic boss bars does not prevent `/baseprogress` from displaying progress. +- [x] `/basesettings bossbar enable` enables automatic progress boss bars idempotently, and `/basesettings bossbar disable` disables them idempotently. +- [x] Invalid boss-bar arguments show command usage, and `enable` and `disable` are offered through autocomplete. +- [x] Disabling automatic boss bars does not prevent `/basesettings` from displaying progress. - [x] The boss-bar preference persists across reconnects and restarts. - [x] Each newly unlocked level displays prominent full-screen title and subtitle text describing the reward. - [x] Unlock notifications occur once per earned level and do not repeat after reconnecting or restarting. +- [x] `/homesettings` aliases `/basesettings` with identical behavior and autocomplete. +- [x] Superseded `/baseprogress`, `/basenavigation`, `/baseflight`, and `/basevisitors` commands and their aliases are no longer registered. ## Related diff --git a/design/user-stories/us-010-administer-player-progression.md b/design/user-stories/us-010-administer-player-progression.md index 86e4f1e..91545c8 100644 --- a/design/user-stories/us-010-administer-player-progression.md +++ b/design/user-stories/us-010-administer-player-progression.md @@ -14,16 +14,20 @@ As a **server administrator**, I want command-based progression controls so that - [x] Administrative commands require `spigotbase.admin`, which server operators receive by default. - [x] Administrative player arguments safely resolve online players and previously known offline players. - [x] Player state remains keyed by UUID while retaining the latest known name for lookup and display. -- [x] `/baseadmin progress ` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings. +- [x] `/baseadmin status ` displays the player's base, counters, earned path levels, active cooldowns, toggles, and visitor settings. - [x] `/baseadmin setlevel ` sets an earned path level while enforcing or explicitly granting required preceding levels. - [x] `/baseadmin setprogress ` updates the selected counter and consistently evaluates reached tiers. -- [x] `/baseadmin reset ` resets a selected path without silently leaving benefits that require it. +- [x] `/baseadmin reset ` resets a selected path without silently leaving benefits that require it. +- [x] `/baseadmin reset base` is rejected and directs the administrator to the confirmed complete-reset command. - [x] `/baseadmin reset all` removes the player's base, progression, active cooldowns, and plugin preferences after confirmation. - [x] Administrators can clear personal and visitor cooldowns independently. - [x] Administrative commands can update configured block requirements, warm-ups, and cooldowns for each level using validated values. - [x] Runtime configuration changes are persisted for subsequent restarts. - [x] Lowered progression requirements are evaluated for a player on their next relevant action rather than immediately updating every stored player. - [x] Every successful mutation reports exactly what changed, and invalid requests make no partial changes. +- [x] `/homeadmin` aliases `/baseadmin` with identical permission requirements and autocomplete. +- [x] Permission-aware autocomplete offers subcommands, online and known offline players, progression paths, valid levels, progress counters, cooldown types, reset paths and confirmation, and numeric configuration keys. +- [x] Invalid and extra arguments show friendly usage without exposing implementation-specific enum errors or modifying state. ## Related diff --git a/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java b/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java index 65b9ec3..e1d7920 100644 --- a/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseAdminCommand.java @@ -1,17 +1,35 @@ package games.dmg.spigotbase; import java.util.Arrays; +import java.util.List; import java.util.Locale; import java.util.Optional; +import java.util.TreeSet; import org.bukkit.Bukkit; 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; import org.bukkit.plugin.java.JavaPlugin; -final class BaseAdminCommand implements CommandExecutor { +final class BaseAdminCommand implements CommandExecutor, TabCompleter { + private static final List SUBCOMMANDS = List.of( + "status", "setlevel", "setprogress", "clearcooldown", "reset", "config" + ); + private static final List PATHS = List.of( + "base", "size", "flight", "warmup", "cooldown" + ); + private static final List COUNTERS = List.of( + "grass_dirt", "stone", "deepslate", "obsidian", "placements", "base_breaks" + ); + private static final List COOLDOWN_TYPES = List.of("personal", "visitor", "all"); + private static final List RESET_PATHS = List.of( + "size", "flight", "warmup", "cooldown", "all" + ); + private static final List LEVELS_THREE = List.of("0", "1", "2", "3"); + private static final List LEVELS_FOUR = List.of("0", "1", "2", "3", "4"); private final JavaPlugin plugin; private final BaseStateManager stateManager; private final AdminProgressionService progressionService; @@ -48,7 +66,7 @@ final class BaseAdminCommand implements CommandExecutor { return true; } return switch (arguments[0].toLowerCase(Locale.ROOT)) { - case "progress" -> showProgress(sender, target.orElseThrow()); + case "status" -> showProgress(sender, target.orElseThrow(), arguments); case "setlevel" -> setLevel(sender, target.orElseThrow(), arguments); case "setprogress" -> setProgress(sender, target.orElseThrow(), arguments); case "clearcooldown" -> clearCooldown(sender, target.orElseThrow(), arguments); @@ -60,7 +78,15 @@ final class BaseAdminCommand implements CommandExecutor { }; } - private boolean showProgress(CommandSender sender, PlayerState player) { + private boolean showProgress( + CommandSender sender, + PlayerState player, + String[] arguments + ) { + if (arguments.length != 2) { + sender.sendMessage(ChatColor.RED + "Usage: /baseadmin status "); + return true; + } sender.sendMessage(ChatColor.GOLD + "=== " + player.latestName() + " Base Progress ==="); sender.sendMessage(ChatColor.YELLOW + "Levels: base=" + player.baseLevel() + " size=" + player.sizeLevel() + " flight=" + player.flightLevel() @@ -89,8 +115,16 @@ final class BaseAdminCommand implements CommandExecutor { sender.sendMessage(ChatColor.RED + "Usage: /baseadmin setlevel "); return true; } + final ProgressionPath path; + try { + path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + sender.sendMessage(ChatColor.RED + + "Usage: /baseadmin setlevel " + + " "); + return true; + } try { - ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT)); int level = Integer.parseInt(arguments[3]); PlayerState updated = stateManager.update( target.playerId(), @@ -100,6 +134,8 @@ final class BaseAdminCommand implements CommandExecutor { stateManager.saveIfDirty(); sender.sendMessage(ChatColor.GREEN + "Set " + updated.latestName() + "'s " + path.name().toLowerCase(Locale.ROOT) + " level to " + level + "."); + } catch (NumberFormatException exception) { + sender.sendMessage(ChatColor.RED + "The level must be an integer."); } catch (IllegalArgumentException exception) { sender.sendMessage(ChatColor.RED + exception.getMessage()); } @@ -116,10 +152,18 @@ final class BaseAdminCommand implements CommandExecutor { + "Usage: /baseadmin setprogress "); return true; } + final ProgressCounter counter; try { - ProgressCounter counter = ProgressCounter.valueOf( + counter = ProgressCounter.valueOf( arguments[2].toUpperCase(Locale.ROOT).replace('-', '_') ); + } catch (IllegalArgumentException exception) { + sender.sendMessage(ChatColor.RED + + "Usage: /baseadmin setprogress " + + " "); + return true; + } + try { long amount = Long.parseLong(arguments[3]); PlayerState updated = stateManager.update( target.playerId(), @@ -142,7 +186,12 @@ final class BaseAdminCommand implements CommandExecutor { PlayerState target, String[] arguments ) { - String selection = arguments.length >= 3 + if (arguments.length < 2 || arguments.length > 3) { + sender.sendMessage(ChatColor.RED + + "Usage: /baseadmin clearcooldown [personal|visitor|all]"); + return true; + } + String selection = arguments.length == 3 ? arguments[2].toLowerCase(Locale.ROOT) : "all"; if (!Arrays.asList("personal", "visitor", "all").contains(selection)) { @@ -186,8 +235,26 @@ final class BaseAdminCommand implements CommandExecutor { sender.sendMessage(ChatColor.GREEN + "Reset all progression for " + target.latestName() + "."); return true; } + if (arguments.length != 3) { + sender.sendMessage(ChatColor.RED + + "Usage: /baseadmin reset [confirm]"); + return true; + } + if (arguments[2].equalsIgnoreCase("base")) { + sender.sendMessage(ChatColor.RED + + "Resetting base removes all progression. Use: /baseadmin reset " + + target.latestName() + " all confirm"); + return true; + } + final ProgressionPath path; + try { + path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException exception) { + sender.sendMessage(ChatColor.RED + + "Usage: /baseadmin reset [confirm]"); + return true; + } try { - ProgressionPath path = ProgressionPath.valueOf(arguments[2].toUpperCase(Locale.ROOT)); stateManager.update( target.playerId(), target.latestName(), @@ -239,6 +306,76 @@ final class BaseAdminCommand implements CommandExecutor { return true; } + @Override + public List onTabComplete( + CommandSender sender, + Command command, + String alias, + String[] arguments + ) { + if (!sender.hasPermission("spigotbase.admin")) { + return List.of(); + } + if (arguments.length == 1) { + String prefix = arguments[0].toLowerCase(Locale.ROOT); + return SUBCOMMANDS.stream() + .filter(subcommand -> subcommand.startsWith(prefix)) + .toList(); + } + if (arguments.length == 2 && arguments[0].equalsIgnoreCase("config")) { + String prefix = arguments[1].toLowerCase(Locale.ROOT); + return plugin.getConfig().getValues(false).entrySet().stream() + .filter(entry -> entry.getValue() instanceof Number) + .map(java.util.Map.Entry::getKey) + .filter(key -> key.toLowerCase(Locale.ROOT).startsWith(prefix)) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + if (arguments.length == 2) { + String prefix = arguments[1].toLowerCase(Locale.ROOT); + TreeSet names = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + stateManager.knownPlayers().values().stream() + .map(PlayerState::latestName) + .forEach(names::add); + if (plugin.getServer() != null) { + plugin.getServer().getOnlinePlayers().stream() + .map(Player::getName) + .forEach(names::add); + } + return names.stream() + .filter(name -> name.toLowerCase(Locale.ROOT).startsWith(prefix)) + .toList(); + } + if (arguments.length == 3) { + List options = switch (arguments[0].toLowerCase(Locale.ROOT)) { + case "setlevel" -> PATHS; + case "setprogress" -> COUNTERS; + case "clearcooldown" -> COOLDOWN_TYPES; + case "reset" -> RESET_PATHS; + default -> List.of(); + }; + return complete(options, arguments[2]); + } + if (arguments.length == 4 && arguments[0].equalsIgnoreCase("setlevel")) { + List levels = switch (arguments[2].toLowerCase(Locale.ROOT)) { + case "base", "cooldown" -> LEVELS_FOUR; + case "size", "flight", "warmup" -> LEVELS_THREE; + default -> List.of(); + }; + return complete(levels, arguments[3]); + } + if (arguments.length == 4 && arguments[0].equalsIgnoreCase("reset") + && arguments[2].equalsIgnoreCase("all")) { + return complete(List.of("confirm"), arguments[3]); + } + return List.of(); + } + + private static List complete(List options, String input) { + String prefix = input.toLowerCase(Locale.ROOT); + return options.stream().filter(option -> option.startsWith(prefix)).toList(); + } + private Optional resolve(String name) { Player online = Bukkit.getPlayerExact(name); if (online != null) { @@ -249,7 +386,7 @@ final class BaseAdminCommand implements CommandExecutor { private static void sendUsage(CommandSender sender) { sender.sendMessage(ChatColor.YELLOW + "Usage: /baseadmin " - + " ..."); + + " ..."); sender.sendMessage(ChatColor.YELLOW + " /baseadmin config "); } } diff --git a/src/main/java/games/dmg/spigotbase/BaseCommand.java b/src/main/java/games/dmg/spigotbase/BaseCommand.java index a64fdfc..1e2e1e7 100644 --- a/src/main/java/games/dmg/spigotbase/BaseCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseCommand.java @@ -1,31 +1,16 @@ package games.dmg.spigotbase; -import java.io.IOException; import org.bukkit.ChatColor; -import org.bukkit.Material; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; -import org.bukkit.inventory.ItemStack; -import org.bukkit.inventory.PlayerInventory; final class BaseCommand implements CommandExecutor { private final BaseTeleportManager teleportManager; - private final BaseStateManager stateManager; - private final VisitorPolicy visitorPolicy; - private final PluginSettingsProvider settings; - BaseCommand( - BaseTeleportManager teleportManager, - BaseStateManager stateManager, - VisitorPolicy visitorPolicy, - PluginSettingsProvider settings - ) { + BaseCommand(BaseTeleportManager teleportManager) { this.teleportManager = teleportManager; - this.stateManager = stateManager; - this.visitorPolicy = visitorPolicy; - this.settings = settings; } @Override @@ -38,95 +23,7 @@ final class BaseCommand implements CommandExecutor { teleportManager.start(player); return true; } - if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) { - purchaseVisitorAccess(player); - return true; - } - player.sendMessage(ChatColor.RED + "Usage: /base [upgrade]"); + player.sendMessage(ChatColor.RED + "Usage: /base"); return true; } - - private void purchaseVisitorAccess(Player player) { - PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); - if (!visitorPolicy.canPurchase(state)) { - player.sendMessage(ChatColor.RED + (state.baseLevel() >= 4 - ? "Base IV is already unlocked." - : "You must unlock Base III before purchasing Base IV.")); - return; - } - int price = settings.current().visitorUnlockDiamondCost(); - Material currency = Material.valueOf(settings.current().visitorCurrencyMaterial()); - PlayerInventory inventory = player.getInventory(); - if (countCurrency(inventory, currency) < price) { - player.sendMessage(ChatColor.RED + "Base IV costs " + price + " " - + currency.name().toLowerCase(java.util.Locale.ROOT) + "."); - return; - } - ItemStack[] snapshot = cloneContents(inventory.getStorageContents()); - removeCurrency(inventory, currency, price); - try { - stateManager.updateAndSave( - player.getUniqueId(), - player.getName(), - current -> current.withBaseLevel(4).withVisitorsEnabled(true) - ); - } catch (IOException | RuntimeException exception) { - inventory.setStorageContents(snapshot); - player.sendMessage(ChatColor.RED + "The upgrade could not be saved; your diamonds were restored."); - return; - } - player.sendTitle( - ChatColor.GOLD + "Base IV Unlocked", - ChatColor.YELLOW + "Visitors may now teleport to your base", - settings.current().titleFadeInTicks(), - settings.current().titleStayTicks(), - settings.current().titleFadeOutTicks() - ); - player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " " - + currency.name().toLowerCase(java.util.Locale.ROOT) + "."); - } - - private static int countCurrency(PlayerInventory inventory, Material currency) { - int count = 0; - for (ItemStack item : inventory.getStorageContents()) { - if (item != null && item.getType() == currency) { - count += item.getAmount(); - } - } - return count; - } - - private static void removeCurrency( - PlayerInventory inventory, - Material currency, - int amount - ) { - ItemStack[] contents = inventory.getStorageContents(); - int remaining = amount; - for (int index = 0; index < contents.length && remaining > 0; index++) { - ItemStack item = contents[index]; - if (item == null || item.getType() != currency) { - continue; - } - int removed = Math.min(remaining, item.getAmount()); - remaining -= removed; - int newAmount = item.getAmount() - removed; - if (newAmount == 0) { - contents[index] = null; - } else { - ItemStack reduced = item.clone(); - reduced.setAmount(newAmount); - contents[index] = reduced; - } - } - inventory.setStorageContents(contents); - } - - private static ItemStack[] cloneContents(ItemStack[] contents) { - ItemStack[] copy = new ItemStack[contents.length]; - for (int index = 0; index < contents.length; index++) { - copy[index] = contents[index] == null ? null : contents[index].clone(); - } - return copy; - } } diff --git a/src/main/java/games/dmg/spigotbase/BaseFlightCommand.java b/src/main/java/games/dmg/spigotbase/BaseFlightCommand.java deleted file mode 100644 index 9795247..0000000 --- a/src/main/java/games/dmg/spigotbase/BaseFlightCommand.java +++ /dev/null @@ -1,68 +0,0 @@ -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, TabCompleter { - private static final List MODES = List.of("on", "off"); - private final BaseStateManager stateManager; - private final BaseFlightController controller; - - BaseFlightCommand(BaseStateManager stateManager, BaseFlightController controller) { - this.stateManager = stateManager; - this.controller = controller; - } - - @Override - public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { - if (!(sender instanceof Player player)) { - sender.sendMessage("Only players can use base flight."); - return true; - } - PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); - if (state.flightLevel() < 1) { - 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(enabled) - ); - if (!state.flightEnabled()) { - controller.removeGrantedFlight(player); - } - stateManager.saveIfDirty(); - player.sendMessage(ChatColor.YELLOW + "Base flight is now " - + (state.flightEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off") - + 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/BaseFlightController.java b/src/main/java/games/dmg/spigotbase/BaseFlightController.java index b5eafbc..3783bd9 100644 --- a/src/main/java/games/dmg/spigotbase/BaseFlightController.java +++ b/src/main/java/games/dmg/spigotbase/BaseFlightController.java @@ -80,7 +80,7 @@ final class BaseFlightController implements Runnable { stateManager.saveIfDirty(); player.sendTitle( ChatColor.GOLD + "Base Flight " + roman(updated.flightLevel()) + " Unlocked", - ChatColor.YELLOW + "Use /baseflight to toggle flight", + ChatColor.YELLOW + "Use /basesettings flight enable to enable flight", settings.current().titleFadeInTicks(), settings.current().titleStayTicks(), settings.current().titleFadeOutTicks() diff --git a/src/main/java/games/dmg/spigotbase/BaseNavigationCommand.java b/src/main/java/games/dmg/spigotbase/BaseNavigationCommand.java deleted file mode 100644 index 1200aec..0000000 --- a/src/main/java/games/dmg/spigotbase/BaseNavigationCommand.java +++ /dev/null @@ -1,73 +0,0 @@ -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 BaseNavigationCommand implements CommandExecutor, TabCompleter { - private static final List MODES = List.of("on", "off"); - - private final BaseStateManager stateManager; - - BaseNavigationCommand(BaseStateManager stateManager) { - this.stateManager = stateManager; - } - - @Override - public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { - if (!(sender instanceof Player player)) { - sender.sendMessage("Only players can use base navigation."); - return true; - } - PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); - if (state.baseLevel() < 2) { - player.sendMessage(ChatColor.RED + "Base II navigation is still locked."); - return true; - } - if (state.base().isEmpty()) { - player.sendMessage(ChatColor.RED + "Set your base before enabling navigation."); - return true; - } - final boolean enabled; - try { - enabled = TogglePreference.resolve(state.navigationEnabled(), arguments); - } catch (IllegalArgumentException exception) { - player.sendMessage(ChatColor.RED + "Usage: /basenavigation [on|off]"); - return true; - } - state = stateManager.update( - player.getUniqueId(), - player.getName(), - current -> current.withNavigationEnabled(enabled) - ); - stateManager.saveIfDirty(); - player.sendMessage(ChatColor.YELLOW + "Base navigation is now " - + (state.navigationEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off") - + ChatColor.YELLOW + "."); - if (state.navigationEnabled() - && !player.getWorld().getUID().equals(state.base().orElseThrow().worldId())) { - player.sendMessage(ChatColor.RED + "Your base is in another world: " - + state.base().orElseThrow().worldName() + "."); - } - 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/BaseProgressCommand.java b/src/main/java/games/dmg/spigotbase/BaseProgressCommand.java deleted file mode 100644 index 1273fc5..0000000 --- a/src/main/java/games/dmg/spigotbase/BaseProgressCommand.java +++ /dev/null @@ -1,121 +0,0 @@ -package games.dmg.spigotbase; - -import org.bukkit.ChatColor; -import org.bukkit.command.Command; -import org.bukkit.command.CommandExecutor; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; - -final class BaseProgressCommand implements CommandExecutor { - private final BaseStateManager stateManager; - private final PluginSettingsProvider settings; - private final TeleportPolicy teleportPolicy; - - BaseProgressCommand(BaseStateManager stateManager, PluginSettingsProvider settings) { - this.stateManager = stateManager; - this.settings = settings; - this.teleportPolicy = new TeleportPolicy(settings); - } - - @Override - public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { - if (!(sender instanceof Player player)) { - sender.sendMessage("Only players have base progression."); - return true; - } - PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); - if (arguments.length == 1 && arguments[0].equalsIgnoreCase("bossbar")) { - state = stateManager.update( - player.getUniqueId(), - player.getName(), - current -> current.withBossBarEnabled(!current.bossBarEnabled()) - ); - stateManager.saveIfDirty(); - player.sendMessage(ChatColor.YELLOW + "Automatic progress boss bars are now " - + (state.bossBarEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off") - + ChatColor.YELLOW + "."); - return true; - } - player.sendMessage(ChatColor.GOLD + "=== Base Progress ==="); - showBasePath(player, state); - showSizePath(player, state); - showFlightPath(player, state); - showWarmupPath(player, state); - showCooldownPath(player, state); - player.sendMessage(ChatColor.GRAY + "Boss bars: " + (state.bossBarEnabled() ? "on" : "off")); - state.base().ifPresentOrElse( - base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " " - + base.x() + ", " + base.y() + ", " + base.z()), - () -> player.sendMessage(ChatColor.GRAY + "Base: not set") - ); - return true; - } - - private void showBasePath(Player player, PlayerState state) { - String detail = switch (state.baseLevel()) { - case 0 -> state.grassAndDirtBroken() + "/" + settings.current().baseUnlockBlocks() - + " grass or dirt → /setbase"; - case 1 -> state.grassAndDirtBroken() + "/" + settings.current().navigationUnlockBlocks() - + " grass or dirt → navigation"; - case 2 -> state.blocksPlacedInBase() + "/" + settings.current().teleportUnlockPlacements() - + " placements → /base"; - case 3 -> settings.current().visitorUnlockDiamondCost() + " diamonds → visitor access"; - case 4 -> "complete; visitor access unlocked"; - default -> "invalid"; - }; - player.sendMessage(ChatColor.YELLOW + "Base " + state.baseLevel() + "/4: " - + ChatColor.GRAY + detail); - } - - private void showSizePath(Player player, PlayerState state) { - String detail = switch (state.sizeLevel()) { - case 0 -> state.stoneBroken() + "/" + settings.current().stoneExpansionBlocks() + " stone"; - case 1 -> state.deepslateBroken() + "/" + settings.current().deepslateExpansionBlocks() + " deepslate"; - case 2 -> state.obsidianBroken() + "/" + settings.current().obsidianExpansionBlocks() + " obsidian"; - case 3 -> "complete; 150-block radius by default"; - default -> "invalid"; - }; - player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Size " - + state.sizeLevel() + "/3: " + ChatColor.GRAY + detail); - } - - private void showFlightPath(Player player, PlayerState state) { - String detail = state.flightLevel() >= 3 - ? "complete; world build height" - : (state.flightLevel() + 1) + " simultaneous elytra required"; - player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Flight " - + state.flightLevel() + "/3: " + ChatColor.GRAY + detail - + "; toggle=" + (state.flightEnabled() ? "on" : "off")); - } - - private void showWarmupPath(Player player, PlayerState state) { - String detail = switch (state.warmupLevel()) { - case 0 -> state.blocksPlacedInBase() + "/" + settings.current().secondWarmupPlacements(); - case 1 -> state.blocksPlacedInBase() + "/" + settings.current().thirdWarmupPlacements(); - case 2 -> state.blocksPlacedInBase() + "/" + settings.current().instantWarmupPlacements(); - case 3 -> "complete"; - default -> "invalid"; - }; - player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Warm-up " - + state.warmupLevel() + "/3: " + ChatColor.GRAY - + DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail); - } - - private void showCooldownPath(Player player, PlayerState state) { - String detail = switch (state.cooldownLevel()) { - case 0 -> state.blocksBrokenInBase() + "/" + settings.current().firstCooldownBreaks(); - case 1 -> state.blocksBrokenInBase() + "/" + settings.current().secondCooldownBreaks(); - case 2 -> state.blocksBrokenInBase() + "/" + settings.current().thirdCooldownBreaks(); - case 3 -> state.blocksBrokenInBase() + "/" + settings.current().instantCooldownBreaks(); - case 4 -> "complete"; - default -> "invalid"; - }; - player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Cooldown " - + state.cooldownLevel() + "/4: " + ChatColor.GRAY - + DurationFormatter.friendly(teleportPolicy.cooldown(state)) + "; " + detail); - } - - private static ChatColor colorForPrerequisite(boolean met) { - return met ? ChatColor.YELLOW : ChatColor.RED; - } -} diff --git a/src/main/java/games/dmg/spigotbase/BaseProgressListener.java b/src/main/java/games/dmg/spigotbase/BaseProgressListener.java index 0b6f9eb..cf6155a 100644 --- a/src/main/java/games/dmg/spigotbase/BaseProgressListener.java +++ b/src/main/java/games/dmg/spigotbase/BaseProgressListener.java @@ -149,7 +149,7 @@ final class BaseProgressListener implements Listener { int level = update.player().baseLevel(); String subtitle = switch (level) { case 1 -> "/setbase is now available"; - case 2 -> "/basenavigation is now available"; + case 2 -> "/basesettings navigation enable is now available"; case 3 -> "/base is now available"; case 4 -> "Visitors can now travel to your base"; default -> "A new base benefit is available"; diff --git a/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java new file mode 100644 index 0000000..183098a --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java @@ -0,0 +1,377 @@ +package games.dmg.spigotbase; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; +import org.bukkit.ChatColor; +import org.bukkit.Material; +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; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; + +final class BaseSettingsCommand implements CommandExecutor, TabCompleter { + private static final List SETTINGS = List.of( + "status", "upgrade", "visitors", "navigation", "flight", "bossbar" + ); + private static final List VISITOR_MODES = List.of("allowed", "blocked"); + private static final List ENABLE_MODES = List.of("enable", "disable"); + private final BaseStateManager stateManager; + private final PluginSettingsProvider settings; + private final TeleportPolicy teleportPolicy; + private final BaseFlightController flightController; + + BaseSettingsCommand( + BaseStateManager stateManager, + PluginSettingsProvider settings, + BaseFlightController flightController + ) { + this.stateManager = stateManager; + this.settings = settings; + this.teleportPolicy = new TeleportPolicy(settings); + this.flightController = flightController; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { + if (!(sender instanceof Player player)) { + sender.sendMessage("Only players have base progression."); + return true; + } + PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); + if (arguments.length == 0 + || arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) { + showStatus(player, state); + return true; + } + if (arguments.length == 1 && arguments[0].equalsIgnoreCase("upgrade")) { + return purchaseVisitorAccess(player, state); + } + if (arguments.length == 2 && arguments[0].equalsIgnoreCase("visitors")) { + return updateVisitors(player, state, arguments[1]); + } + if (arguments.length == 2 && arguments[0].equalsIgnoreCase("navigation")) { + return updateNavigation(player, state, arguments[1]); + } + if (arguments.length == 2 && arguments[0].equalsIgnoreCase("flight")) { + return updateFlight(player, state, arguments[1]); + } + if (arguments.length == 2 && arguments[0].equalsIgnoreCase("bossbar")) { + return updateBossBar(player, arguments[1]); + } + sendUsage(player); + return true; + } + + private boolean purchaseVisitorAccess(Player player, PlayerState state) { + if (!new VisitorPolicy().canPurchase(state)) { + player.sendMessage(ChatColor.RED + (state.baseLevel() >= 4 + ? "Base IV is already unlocked." + : "You must unlock Base III before purchasing Base IV.")); + return true; + } + int price = settings.current().visitorUnlockDiamondCost(); + Material currency = Material.valueOf(settings.current().visitorCurrencyMaterial()); + PlayerInventory inventory = player.getInventory(); + if (countCurrency(inventory, currency) < price) { + player.sendMessage(ChatColor.RED + "Base IV costs " + price + " " + + currency.name().toLowerCase(java.util.Locale.ROOT) + "."); + return true; + } + ItemStack[] snapshot = cloneContents(inventory.getStorageContents()); + removeCurrency(inventory, currency, price); + try { + stateManager.updateAndSave( + player.getUniqueId(), + player.getName(), + current -> current.withBaseLevel(4).withVisitorsEnabled(true) + ); + } catch (IOException | RuntimeException exception) { + inventory.setStorageContents(snapshot); + player.sendMessage(ChatColor.RED + + "The upgrade could not be saved; your diamonds were restored."); + return true; + } + player.sendTitle( + ChatColor.GOLD + "Base IV Unlocked", + ChatColor.YELLOW + "Visitors may now teleport to your base", + settings.current().titleFadeInTicks(), + settings.current().titleStayTicks(), + settings.current().titleFadeOutTicks() + ); + player.sendMessage(ChatColor.GREEN + "Base IV unlocked for " + price + " " + + currency.name().toLowerCase(java.util.Locale.ROOT) + "."); + return true; + } + + private boolean updateVisitors(Player player, PlayerState state, String mode) { + if (state.baseLevel() < 4) { + player.sendMessage(ChatColor.RED + "Base IV visitor access is still locked."); + return true; + } + final boolean enabled; + if (mode.equalsIgnoreCase("allowed")) { + enabled = true; + } else if (mode.equalsIgnoreCase("blocked")) { + enabled = false; + } else { + sendUsage(player); + return true; + } + state = stateManager.update( + player.getUniqueId(), + player.getName(), + current -> current.withVisitorsEnabled(enabled) + ); + stateManager.saveIfDirty(); + player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now " + + (state.visitorsEnabled() ? ChatColor.GREEN + "allowed" : ChatColor.RED + "blocked") + + ChatColor.YELLOW + "."); + return true; + } + + private boolean updateNavigation(Player player, PlayerState state, String mode) { + if (state.baseLevel() < 2) { + player.sendMessage(ChatColor.RED + "Base II navigation is still locked."); + return true; + } + if (state.base().isEmpty()) { + player.sendMessage(ChatColor.RED + "Set your base before enabling navigation."); + return true; + } + Boolean enabled = enabledMode(mode); + if (enabled == null) { + sendUsage(player); + return true; + } + state = stateManager.update( + player.getUniqueId(), + player.getName(), + current -> current.withNavigationEnabled(enabled) + ); + stateManager.saveIfDirty(); + player.sendMessage(ChatColor.YELLOW + "Base navigation is now " + + (state.navigationEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled") + + ChatColor.YELLOW + "."); + if (state.navigationEnabled() + && !player.getWorld().getUID().equals(state.base().orElseThrow().worldId())) { + player.sendMessage(ChatColor.RED + "Your base is in another world: " + + state.base().orElseThrow().worldName() + "."); + } + return true; + } + + private boolean updateFlight(Player player, PlayerState state, String mode) { + if (state.flightLevel() < 1) { + player.sendMessage(ChatColor.RED + "Base flight is still locked."); + return true; + } + Boolean enabled = enabledMode(mode); + if (enabled == null) { + sendUsage(player); + return true; + } + state = stateManager.update( + player.getUniqueId(), + player.getName(), + current -> current.withFlightEnabled(enabled) + ); + if (!state.flightEnabled()) { + flightController.removeGrantedFlight(player); + } + stateManager.saveIfDirty(); + player.sendMessage(ChatColor.YELLOW + "Base flight is now " + + (state.flightEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled") + + ChatColor.YELLOW + "."); + return true; + } + + private boolean updateBossBar(Player player, String mode) { + Boolean enabled = enabledMode(mode); + if (enabled == null) { + sendUsage(player); + return true; + } + PlayerState state = stateManager.update( + player.getUniqueId(), + player.getName(), + current -> current.withBossBarEnabled(enabled) + ); + stateManager.saveIfDirty(); + player.sendMessage(ChatColor.YELLOW + "Automatic progress boss bars are now " + + (state.bossBarEnabled() ? ChatColor.GREEN + "enabled" : ChatColor.RED + "disabled") + + ChatColor.YELLOW + "."); + return true; + } + + private void showStatus(Player player, PlayerState state) { + player.sendMessage(ChatColor.GOLD + "=== Base Progress ==="); + showBasePath(player, state); + showSizePath(player, state); + showFlightPath(player, state); + showWarmupPath(player, state); + showCooldownPath(player, state); + player.sendMessage(ChatColor.GRAY + "Settings: visitors=" + + (state.visitorsEnabled() ? "allowed" : "blocked") + + " navigation=" + (state.navigationEnabled() ? "enabled" : "disabled") + + " flight=" + (state.flightEnabled() ? "enabled" : "disabled") + + " bossbar=" + (state.bossBarEnabled() ? "enabled" : "disabled")); + state.base().ifPresentOrElse( + base -> player.sendMessage(ChatColor.GRAY + "Base: " + base.worldName() + " " + + base.x() + ", " + base.y() + ", " + base.z()), + () -> player.sendMessage(ChatColor.GRAY + "Base: not set") + ); + } + + private void showBasePath(Player player, PlayerState state) { + String detail = switch (state.baseLevel()) { + case 0 -> state.grassAndDirtBroken() + "/" + settings.current().baseUnlockBlocks() + + " grass or dirt → /setbase"; + case 1 -> state.grassAndDirtBroken() + "/" + settings.current().navigationUnlockBlocks() + + " grass or dirt → navigation"; + case 2 -> state.blocksPlacedInBase() + "/" + settings.current().teleportUnlockPlacements() + + " placements → /base"; + case 3 -> settings.current().visitorUnlockDiamondCost() + + " diamonds → /basesettings upgrade"; + case 4 -> "complete; visitor access unlocked"; + default -> "invalid"; + }; + player.sendMessage(ChatColor.YELLOW + "Base " + state.baseLevel() + "/4: " + + ChatColor.GRAY + detail); + } + + private void showSizePath(Player player, PlayerState state) { + String detail = switch (state.sizeLevel()) { + case 0 -> state.stoneBroken() + "/" + settings.current().stoneExpansionBlocks() + " stone"; + case 1 -> state.deepslateBroken() + "/" + settings.current().deepslateExpansionBlocks() + " deepslate"; + case 2 -> state.obsidianBroken() + "/" + settings.current().obsidianExpansionBlocks() + " obsidian"; + case 3 -> "complete; 150-block radius by default"; + default -> "invalid"; + }; + player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Size " + + state.sizeLevel() + "/3: " + ChatColor.GRAY + detail); + } + + private void showFlightPath(Player player, PlayerState state) { + String detail = state.flightLevel() >= 3 + ? "complete; world build height" + : (state.flightLevel() + 1) + " simultaneous elytra required"; + player.sendMessage(colorForPrerequisite(state.baseLevel() >= 1) + "Base Flight " + + state.flightLevel() + "/3: " + ChatColor.GRAY + detail + + "; setting=" + (state.flightEnabled() ? "enabled" : "disabled")); + } + + private void showWarmupPath(Player player, PlayerState state) { + String detail = switch (state.warmupLevel()) { + case 0 -> state.blocksPlacedInBase() + "/" + settings.current().secondWarmupPlacements(); + case 1 -> state.blocksPlacedInBase() + "/" + settings.current().thirdWarmupPlacements(); + case 2 -> state.blocksPlacedInBase() + "/" + settings.current().instantWarmupPlacements(); + case 3 -> "complete"; + default -> "invalid"; + }; + player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Warm-up " + + state.warmupLevel() + "/3: " + ChatColor.GRAY + + DurationFormatter.friendly(teleportPolicy.warmup(state)) + "; " + detail); + } + + private void showCooldownPath(Player player, PlayerState state) { + String detail = switch (state.cooldownLevel()) { + case 0 -> state.blocksBrokenInBase() + "/" + settings.current().firstCooldownBreaks(); + case 1 -> state.blocksBrokenInBase() + "/" + settings.current().secondCooldownBreaks(); + case 2 -> state.blocksBrokenInBase() + "/" + settings.current().thirdCooldownBreaks(); + case 3 -> state.blocksBrokenInBase() + "/" + settings.current().instantCooldownBreaks(); + case 4 -> "complete"; + default -> "invalid"; + }; + player.sendMessage(colorForPrerequisite(state.baseLevel() >= 3) + "Teleport Cooldown " + + state.cooldownLevel() + "/4: " + ChatColor.GRAY + + DurationFormatter.friendly(teleportPolicy.cooldown(state)) + "; " + detail); + } + + @Override + public List onTabComplete( + CommandSender sender, + Command command, + String alias, + String[] arguments + ) { + if (arguments.length == 1) { + String prefix = arguments[0].toLowerCase(Locale.ROOT); + return SETTINGS.stream().filter(setting -> setting.startsWith(prefix)).toList(); + } + if (arguments.length == 2) { + List modes = arguments[0].equalsIgnoreCase("visitors") + ? VISITOR_MODES + : switch (arguments[0].toLowerCase(Locale.ROOT)) { + case "navigation", "flight", "bossbar" -> ENABLE_MODES; + default -> List.of(); + }; + String prefix = arguments[1].toLowerCase(Locale.ROOT); + return modes.stream().filter(mode -> mode.startsWith(prefix)).toList(); + } + return List.of(); + } + + private static int countCurrency(PlayerInventory inventory, Material currency) { + int count = 0; + for (ItemStack item : inventory.getStorageContents()) { + if (item != null && item.getType() == currency) { + count += item.getAmount(); + } + } + return count; + } + + private static void removeCurrency(PlayerInventory inventory, Material currency, int amount) { + ItemStack[] contents = inventory.getStorageContents(); + int remaining = amount; + for (int index = 0; index < contents.length && remaining > 0; index++) { + ItemStack item = contents[index]; + if (item == null || item.getType() != currency) { + continue; + } + int removed = Math.min(remaining, item.getAmount()); + remaining -= removed; + int newAmount = item.getAmount() - removed; + if (newAmount == 0) { + contents[index] = null; + } else { + ItemStack reduced = item.clone(); + reduced.setAmount(newAmount); + contents[index] = reduced; + } + } + inventory.setStorageContents(contents); + } + + private static ItemStack[] cloneContents(ItemStack[] contents) { + ItemStack[] copy = new ItemStack[contents.length]; + for (int index = 0; index < contents.length; index++) { + copy[index] = contents[index] == null ? null : contents[index].clone(); + } + return copy; + } + + private static Boolean enabledMode(String mode) { + if (mode.equalsIgnoreCase("enable")) { + return true; + } + if (mode.equalsIgnoreCase("disable")) { + return false; + } + return null; + } + + private static ChatColor colorForPrerequisite(boolean met) { + return met ? ChatColor.YELLOW : ChatColor.RED; + } + + private static void sendUsage(Player player) { + player.sendMessage(ChatColor.RED + "Usage: /basesettings " + + "[status|upgrade|visitors |navigation " + + "|flight |bossbar ]"); + } +} diff --git a/src/main/java/games/dmg/spigotbase/BaseVisitorsCommand.java b/src/main/java/games/dmg/spigotbase/BaseVisitorsCommand.java deleted file mode 100644 index 758a539..0000000 --- a/src/main/java/games/dmg/spigotbase/BaseVisitorsCommand.java +++ /dev/null @@ -1,63 +0,0 @@ -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, TabCompleter { - private static final List MODES = List.of("on", "off"); - private final BaseStateManager stateManager; - - BaseVisitorsCommand(BaseStateManager stateManager) { - this.stateManager = stateManager; - } - - @Override - public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { - if (!(sender instanceof Player player)) { - sender.sendMessage("Only players can manage base visitors."); - return true; - } - PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); - if (state.baseLevel() < 4) { - 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(enabled) - ); - stateManager.saveIfDirty(); - player.sendMessage(ChatColor.YELLOW + "Visitor teleports are now " - + (state.visitorsEnabled() ? ChatColor.GREEN + "on" : ChatColor.RED + "off") - + 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 8092fc3..1356ab5 100644 --- a/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java +++ b/src/main/java/games/dmg/spigotbase/SpigotBasePlugin.java @@ -65,32 +65,25 @@ public final class SpigotBasePlugin extends JavaPlugin { getServer().getPluginManager().registerEvents(teleportManager, this); command("setbase").setExecutor(new SetBaseCommand(stateManager, baseService, Clock.systemUTC())); - command("base").setExecutor( - new BaseCommand(teleportManager, stateManager, visitorPolicy, settingsProvider) + command("base").setExecutor(new BaseCommand(teleportManager)); + BaseSettingsCommand settingsCommand = new BaseSettingsCommand( + stateManager, + settingsProvider, + flightController ); - command("baseprogress").setExecutor( - new BaseProgressCommand(stateManager, settingsProvider) - ); - BaseNavigationCommand navigationCommand = new BaseNavigationCommand(stateManager); - command("basenavigation").setExecutor(navigationCommand); - command("basenavigation").setTabCompleter(navigationCommand); - 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); + command("basesettings").setExecutor(settingsCommand); + command("basesettings").setTabCompleter(settingsCommand); GoToBaseCommand goToBaseCommand = new GoToBaseCommand(stateManager, teleportManager); command("gotobase").setExecutor(goToBaseCommand); command("gotobase").setTabCompleter(goToBaseCommand); - command("baseadmin").setExecutor( - new BaseAdminCommand( - this, - stateManager, - new AdminProgressionService(settingsProvider), - settingsProvider - ) + BaseAdminCommand adminCommand = new BaseAdminCommand( + this, + stateManager, + new AdminProgressionService(settingsProvider), + settingsProvider ); + command("baseadmin").setExecutor(adminCommand); + command("baseadmin").setTabCompleter(adminCommand); getServer().getScheduler().runTaskTimer( this, diff --git a/src/main/java/games/dmg/spigotbase/TogglePreference.java b/src/main/java/games/dmg/spigotbase/TogglePreference.java deleted file mode 100644 index 9a2c8e4..0000000 --- a/src/main/java/games/dmg/spigotbase/TogglePreference.java +++ /dev/null @@ -1,22 +0,0 @@ -package games.dmg.spigotbase; - -import java.util.Locale; - -final class TogglePreference { - private TogglePreference() { - } - - 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"); - }; - } -} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index d1431e3..5419ce1 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -10,31 +10,21 @@ commands: usage: /setbase aliases: [sethome] base: - description: Teleport to or upgrade your base. - usage: /base [upgrade] + description: Teleport to your base. + usage: /base aliases: [home] - basenavigation: - description: Toggle particle navigation toward your base. - usage: /basenavigation [on|off] - aliases: [homenavigation] - baseflight: - description: Toggle flight within your base. - usage: /baseflight [on|off] - aliases: [homeflight] - basevisitors: - description: Toggle visitor access to your base. - usage: /basevisitors [on|off] - aliases: [homevisitors] gotobase: description: Visit an available player base. usage: /gotobase aliases: [visit] - baseprogress: - description: View progression or toggle progress boss bars. - usage: /baseprogress [bossbar] + basesettings: + description: View progression, upgrade, and manage base settings. + usage: /basesettings [status|upgrade|visitors|navigation|flight|bossbar] + aliases: [homesettings] baseadmin: description: Administer Spigot Base. usage: /baseadmin + aliases: [homeadmin] permission: spigotbase.admin permissions: spigotbase.admin: diff --git a/src/test/java/games/dmg/spigotbase/BaseAdminCommandTest.java b/src/test/java/games/dmg/spigotbase/BaseAdminCommandTest.java new file mode 100644 index 0000000..6793e6c --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseAdminCommandTest.java @@ -0,0 +1,227 @@ +package games.dmg.spigotbase; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +final class BaseAdminCommandTest { + @Test + void invalidPathShowsFriendlyUsage() { + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigotbase.admin")).thenReturn(true); + PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder"); + BaseStateManager stateManager = mock(BaseStateManager.class); + when(stateManager.findByName("Builder")).thenReturn(Optional.of(target)); + BaseAdminCommand command = new BaseAdminCommand( + mock(JavaPlugin.class), + stateManager, + mock(AdminProgressionService.class), + new PluginSettingsProvider(PluginSettings.from(Map.of())) + ); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null); + command.onCommand(sender, null, "baseadmin", + new String[] {"setlevel", "Builder", "unknown", "1"}); + } + + verify(sender).sendMessage(ChatColor.RED + + "Usage: /baseadmin setlevel "); + verify(stateManager, never()).update(any(), anyString(), any()); + } + + @Test + void autocompletesValidLevelsAndResetConfirmation() { + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigotbase.admin")).thenReturn(true); + BaseAdminCommand command = new BaseAdminCommand( + mock(JavaPlugin.class), + mock(BaseStateManager.class), + mock(AdminProgressionService.class), + new PluginSettingsProvider(PluginSettings.from(Map.of())) + ); + + assertEquals( + List.of("0", "1", "2", "3", "4"), + command.onTabComplete(sender, null, "baseadmin", + new String[] {"setlevel", "Builder", "base", ""}) + ); + assertEquals( + List.of("0", "1", "2", "3"), + command.onTabComplete(sender, null, "baseadmin", + new String[] {"setlevel", "Builder", "flight", ""}) + ); + assertEquals( + List.of("confirm"), + command.onTabComplete(sender, null, "baseadmin", + new String[] {"reset", "Builder", "all", ""}) + ); + } + + @Test + void autocompletesCommandSpecificOptions() { + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigotbase.admin")).thenReturn(true); + BaseAdminCommand command = new BaseAdminCommand( + mock(JavaPlugin.class), + mock(BaseStateManager.class), + mock(AdminProgressionService.class), + new PluginSettingsProvider(PluginSettings.from(Map.of())) + ); + + assertEquals( + List.of("base", "size", "flight", "warmup", "cooldown"), + command.onTabComplete(sender, null, "baseadmin", + new String[] {"setlevel", "Builder", ""}) + ); + assertEquals( + List.of("grass_dirt", "stone", "deepslate", "obsidian", "placements", "base_breaks"), + command.onTabComplete(sender, null, "baseadmin", + new String[] {"setprogress", "Builder", ""}) + ); + assertEquals( + List.of("personal", "visitor", "all"), + command.onTabComplete(sender, null, "baseadmin", + new String[] {"clearcooldown", "Builder", ""}) + ); + assertEquals( + List.of("size", "flight", "warmup", "cooldown", "all"), + command.onTabComplete(sender, null, "baseadmin", + new String[] {"reset", "Builder", ""}) + ); + } + + @Test + void autocompletesNumericConfigurationKeys() { + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigotbase.admin")).thenReturn(true); + JavaPlugin plugin = mock(JavaPlugin.class); + FileConfiguration configuration = mock(FileConfiguration.class); + when(plugin.getConfig()).thenReturn(configuration); + when(configuration.getValues(false)).thenReturn(Map.of( + "base-unlock-blocks", 250, + "visitor-currency-material", "DIAMOND", + "title-stay-ticks", 70 + )); + BaseAdminCommand command = new BaseAdminCommand( + plugin, + mock(BaseStateManager.class), + mock(AdminProgressionService.class), + new PluginSettingsProvider(PluginSettings.from(Map.of())) + ); + + assertEquals( + List.of("base-unlock-blocks"), + command.onTabComplete(sender, null, "baseadmin", new String[] {"config", "base"}) + ); + } + + @Test + void autocompletesKnownOfflinePlayers() { + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigotbase.admin")).thenReturn(true); + BaseStateManager stateManager = mock(BaseStateManager.class); + PlayerState alex = PlayerState.newPlayer(UUID.randomUUID(), "Alex"); + PlayerState blake = PlayerState.newPlayer(UUID.randomUUID(), "Blake"); + when(stateManager.knownPlayers()).thenReturn(Map.of( + alex.playerId(), alex, + blake.playerId(), blake + )); + BaseAdminCommand command = new BaseAdminCommand( + mock(JavaPlugin.class), + stateManager, + mock(AdminProgressionService.class), + new PluginSettingsProvider(PluginSettings.from(Map.of())) + ); + + assertEquals( + List.of("Alex", "Blake"), + command.onTabComplete(sender, null, "baseadmin", new String[] {"status", ""}) + ); + } + + @Test + void autocompletesAdminSubcommandsForPermittedSenders() { + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigotbase.admin")).thenReturn(true); + BaseAdminCommand command = new BaseAdminCommand( + mock(JavaPlugin.class), + mock(BaseStateManager.class), + mock(AdminProgressionService.class), + new PluginSettingsProvider(PluginSettings.from(Map.of())) + ); + + assertEquals( + List.of("status", "setlevel", "setprogress", "clearcooldown", "reset", "config"), + command.onTabComplete(sender, null, "baseadmin", new String[] {""}) + ); + } + + @Test + void resetBaseRequiresConfirmedCompleteReset() { + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigotbase.admin")).thenReturn(true); + PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder"); + BaseStateManager stateManager = mock(BaseStateManager.class); + when(stateManager.findByName("Builder")).thenReturn(Optional.of(target)); + BaseAdminCommand command = new BaseAdminCommand( + mock(JavaPlugin.class), + stateManager, + mock(AdminProgressionService.class), + new PluginSettingsProvider(PluginSettings.from(Map.of())) + ); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null); + command.onCommand( + sender, + null, + "baseadmin", + new String[] {"reset", "Builder", "base"} + ); + } + + verify(stateManager, never()).update(any(), anyString(), any()); + verify(sender).sendMessage(ChatColor.RED + + "Resetting base removes all progression. Use: /baseadmin reset Builder all confirm"); + } + + @Test + void statusDisplaysKnownPlayerProgress() { + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigotbase.admin")).thenReturn(true); + PlayerState target = PlayerState.newPlayer(UUID.randomUUID(), "Builder"); + BaseStateManager stateManager = mock(BaseStateManager.class); + when(stateManager.findByName("Builder")).thenReturn(Optional.of(target)); + BaseAdminCommand command = new BaseAdminCommand( + mock(JavaPlugin.class), + stateManager, + mock(AdminProgressionService.class), + new PluginSettingsProvider(PluginSettings.from(Map.of())) + ); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayerExact("Builder")).thenReturn(null); + command.onCommand(sender, null, "baseadmin", new String[] {"status", "Builder"}); + } + + verify(sender).sendMessage(ChatColor.GOLD + "=== Builder Base Progress ==="); + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseFlightCommandTest.java b/src/test/java/games/dmg/spigotbase/BaseFlightCommandTest.java deleted file mode 100644 index ee1cd8b..0000000 --- a/src/test/java/games/dmg/spigotbase/BaseFlightCommandTest.java +++ /dev/null @@ -1,59 +0,0 @@ -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/BaseSettingsCommandTest.java b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java new file mode 100644 index 0000000..be5a59b --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java @@ -0,0 +1,230 @@ +package games.dmg.spigotbase; + +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.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.UnaryOperator; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.Test; + +final class BaseSettingsCommandTest { + @Test + void invalidArgumentsShowUsage() { + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getName()).thenReturn("Builder"); + BaseStateManager stateManager = mock(BaseStateManager.class); + when(stateManager.player(playerId, "Builder")) + .thenReturn(PlayerState.newPlayer(playerId, "Builder")); + BaseSettingsCommand command = new BaseSettingsCommand( + stateManager, + new PluginSettingsProvider(PluginSettings.from(Map.of())), + mock(BaseFlightController.class) + ); + + command.onCommand(player, null, "basesettings", new String[] {"unknown"}); + + verify(player).sendMessage(org.mockito.ArgumentMatchers.argThat( + message -> message.contains("Usage: /basesettings") + )); + } + + @Test + void autocompletesValuesForEachSetting() { + BaseSettingsCommand command = new BaseSettingsCommand( + mock(BaseStateManager.class), + new PluginSettingsProvider(PluginSettings.from(Map.of())), + mock(BaseFlightController.class) + ); + + assertEquals( + List.of("allowed"), + command.onTabComplete(null, null, "basesettings", new String[] {"visitors", "a"}) + ); + for (String setting : List.of("navigation", "flight", "bossbar")) { + assertEquals( + List.of("disable"), + command.onTabComplete(null, null, "basesettings", new String[] {setting, "d"}) + ); + } + } + + @Test + void autocompletesSettingNames() { + BaseSettingsCommand command = new BaseSettingsCommand( + mock(BaseStateManager.class), + new PluginSettingsProvider(PluginSettings.from(Map.of())), + mock(BaseFlightController.class) + ); + + assertEquals( + List.of("status", "upgrade", "visitors", "navigation", "flight", "bossbar"), + command.onTabComplete(null, null, "basesettings", new String[] {""}) + ); + } + + @Test + void upgradePurchasesBaseIv() throws Exception { + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getName()).thenReturn("Host"); + PlayerInventory inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + when(inventory.getStorageContents()).thenReturn(new ItemStack[] { + new ItemStack(Material.DIAMOND, 64), + new ItemStack(Material.DIAMOND, 64) + }); + + PlayerState current = PlayerState.newPlayer(playerId, "Host") + .withAdministrativeLevels(3, 0, 0, 0, 0, false, false, false) + .withBase( + new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0), + Instant.EPOCH + ); + AtomicReference updated = new AtomicReference<>(); + BaseStateManager stateManager = mock(BaseStateManager.class); + when(stateManager.player(playerId, "Host")).thenReturn(current); + when(stateManager.updateAndSave(any(), anyString(), any())).thenAnswer(invocation -> { + @SuppressWarnings("unchecked") + UnaryOperator operation = invocation.getArgument(2); + PlayerState result = operation.apply(current); + updated.set(result); + return result; + }); + BaseSettingsCommand command = new BaseSettingsCommand( + stateManager, + new PluginSettingsProvider(PluginSettings.from(Map.of())), + mock(BaseFlightController.class) + ); + + command.onCommand(player, null, "basesettings", new String[] {"upgrade"}); + + assertEquals(4, updated.get().baseLevel()); + assertTrue(updated.get().visitorsEnabled()); + } + + @Test + void bossbarEnableIsIdempotent() { + UUID playerId = UUID.randomUUID(); + PlayerState current = PlayerState.newPlayer(playerId, "Miner"); + + CommandResult result = execute(current, "bossbar", "enable"); + + assertTrue(result.updated().bossBarEnabled()); + } + + @Test + void flightDisableIsIdempotentAndRemovesGrantedFlight() { + UUID playerId = UUID.randomUUID(); + PlayerState current = PlayerState.newPlayer(playerId, "Pilot") + .withAdministrativeLevels(1, 0, 1, 0, 0, false, true, false); + + CommandResult result = execute(current, "flight", "disable"); + + assertFalse(result.updated().flightEnabled()); + verify(result.flightController()).removeGrantedFlight(any(Player.class)); + } + + @Test + void navigationDisableIsIdempotent() { + UUID playerId = UUID.randomUUID(); + PlayerState current = PlayerState.newPlayer(playerId, "Explorer") + .withAdministrativeLevels(2, 0, 0, 0, 0, true, false, false) + .withBase( + new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0), + Instant.EPOCH + ); + + CommandResult result = execute(current, "navigation", "disable"); + + assertFalse(result.updated().navigationEnabled()); + } + + @Test + void visitorsAllowedIsIdempotent() { + UUID playerId = UUID.randomUUID(); + PlayerState current = PlayerState.newPlayer(playerId, "Host") + .withAdministrativeLevels(4, 0, 0, 0, 0, false, false, true); + + CommandResult result = execute(current, "visitors", "allowed"); + + assertTrue(result.updated().visitorsEnabled()); + } + + @Test + void defaultAndStatusDisplayTheFullProgressReport() { + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getName()).thenReturn("Builder"); + + BaseStateManager stateManager = mock(BaseStateManager.class); + when(stateManager.player(playerId, "Builder")) + .thenReturn(PlayerState.newPlayer(playerId, "Builder")); + BaseSettingsCommand command = new BaseSettingsCommand( + stateManager, + new PluginSettingsProvider(PluginSettings.from(Map.of())), + mock(BaseFlightController.class) + ); + + command.onCommand(player, null, "basesettings", new String[0]); + command.onCommand(player, null, "basesettings", new String[] {"status"}); + + verify(player, times(2)).sendMessage(ChatColor.GOLD + "=== Base Progress ==="); + verify(player, times(2)).sendMessage( + org.mockito.ArgumentMatchers.argThat(message -> + message.contains("Settings: visitors=") + && message.contains("navigation=") + && message.contains("flight=") + && message.contains("bossbar=") + ) + ); + } + + private static CommandResult execute(PlayerState current, String... arguments) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(current.playerId()); + when(player.getName()).thenReturn(current.latestName()); + + AtomicReference updated = new AtomicReference<>(); + BaseStateManager stateManager = mock(BaseStateManager.class); + when(stateManager.player(current.playerId(), current.latestName())).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; + }); + BaseFlightController flightController = mock(BaseFlightController.class); + BaseSettingsCommand command = new BaseSettingsCommand( + stateManager, + new PluginSettingsProvider(PluginSettings.from(Map.of())), + flightController + ); + + command.onCommand(player, null, "basesettings", arguments); + return new CommandResult(updated.get(), flightController); + } + + private record CommandResult(PlayerState updated, BaseFlightController flightController) { + } +} diff --git a/src/test/java/games/dmg/spigotbase/BaseVisitorsCommandTest.java b/src/test/java/games/dmg/spigotbase/BaseVisitorsCommandTest.java deleted file mode 100644 index 08b2118..0000000 --- a/src/test/java/games/dmg/spigotbase/BaseVisitorsCommandTest.java +++ /dev/null @@ -1,53 +0,0 @@ -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/PluginMetadataTest.java b/src/test/java/games/dmg/spigotbase/PluginMetadataTest.java index 1bac390..3813618 100644 --- a/src/test/java/games/dmg/spigotbase/PluginMetadataTest.java +++ b/src/test/java/games/dmg/spigotbase/PluginMetadataTest.java @@ -1,6 +1,7 @@ package games.dmg.spigotbase; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import java.io.InputStream; @@ -10,6 +11,34 @@ import org.junit.jupiter.api.Test; import org.yaml.snakeyaml.Yaml; final class PluginMetadataTest { + @Test + void homeadminAliasesBaseadmin() { + Map commands = commands(); + + Map admin = (Map) commands.get("baseadmin"); + assertEquals(List.of("homeadmin"), admin.get("aliases")); + } + + @Test + void doesNotDeclareSupersededSettingsCommands() { + Map commands = commands(); + + for (String command : List.of( + "baseprogress", "basenavigation", "baseflight", "basevisitors" + )) { + assertFalse(commands.containsKey(command), command); + } + } + + @Test + void homesettingsAliasesBasesettings() { + Map commands = commands(); + + Map settings = (Map) commands.get("basesettings"); + assertNotNull(settings); + assertEquals(List.of("homesettings"), settings.get("aliases")); + } + @Test void sethomeAliasesSetbase() { Map commands = commands(); @@ -26,30 +55,6 @@ 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(); @@ -70,8 +75,7 @@ final class PluginMetadataTest { Map commands = (Map) plugin.get("commands"); for (String command : new String[] { - "setbase", "base", "basenavigation", "baseflight", - "basevisitors", "gotobase", "baseprogress", "baseadmin" + "setbase", "base", "gotobase", "basesettings", "baseadmin" }) { assertNotNull(commands.get(command), command); } diff --git a/src/test/java/games/dmg/spigotbase/TogglePreferenceTest.java b/src/test/java/games/dmg/spigotbase/TogglePreferenceTest.java deleted file mode 100644 index 289696f..0000000 --- a/src/test/java/games/dmg/spigotbase/TogglePreferenceTest.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 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"}) - ); - } -}