diff --git a/README.md b/README.md index f8ab768..3d12412 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ The plugin JAR is written to `build/libs/`. /base (alias: /home) /gotobase (alias: /visit ) /basesettings (alias: /homesettings) +/basesettings ui /basesettings status /basesettings upgrade /basesettings pocket upgrade @@ -39,6 +40,8 @@ The plugin JAR is written to `build/libs/`. /basesettings bossbar ``` +`/basesettings` and `/basesettings ui` open Minecraft's native dialog dashboard. The dashboard summarizes Base and Pocket Base progression, provides unlocked setting controls and purchase confirmations, and marks unavailable controls as locked. `/basesettings status` retains the detailed chat report. + Navigation particles appear only in the base's world and when the player is more than 25 blocks beyond the current base border. After 250 Survival-mode block placements anywhere by default, the spawnable overlay can mark nearby dark hostile-mob spawning surfaces inside the player's base with owner-only red particles. The threshold is configurable. diff --git a/design/log.md b/design/log.md index ccb13b6..2f10562 100644 --- a/design/log.md +++ b/design/log.md @@ -114,3 +114,11 @@ description: Chronological record of material decisions affecting the Spigot Bas - Applied biome metadata across unlocked columns and new expansion rings without changing blocks, entities, inventories, portals, progression, or mob-spawning preferences. - Added an atomic, configurable biome-change payment that defaults to 16 netherite blocks and restores the prior biome and payment on failure. - Verified the feature with `./gradlew clean check jar`. + +## 2026-08-24 — Native base settings dialog + +- Changed `/basesettings` and `/homesettings` with no arguments to open a native Minecraft dialog dashboard, with explicit `ui` subcommands providing the same behavior. +- Added progression summaries, current and locked setting states, toggles, Pocket Base controls, biome selection, and confirmations for Base IV, Pocket Base, and biome purchases. +- Retained the detailed chat report under `/basesettings status` and kept dialog actions routed through the existing validated command behavior. +- Added dialog routing, autocomplete, alias, non-player, generated-control, locked-state, and purchase-confirmation coverage. +- Verified the feature with `./gradlew clean check jar`. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index 51788c2..6bfe5b1 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -25,3 +25,4 @@ description: Catalog of user stories for the Spigot Base plugin. 17. [US-017: Expand a Pocket Base](us-017-expand-a-pocket-base.md) 18. [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md) 19. [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md) +20. [US-020: Control a base through a dialog UI](us-020-control-base-through-dialog-ui.md) 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 3738ec8..d81f198 100644 --- a/design/user-stories/us-009-view-progression-and-notifications.md +++ b/design/user-stories/us-009-view-progression-and-notifications.md @@ -11,7 +11,7 @@ As a **player**, I want to inspect my progression and receive timely milestone f ## Acceptance criteria -- [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] `/basesettings status` shows 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. @@ -19,7 +19,7 @@ As a **player**, I want to inspect my progression and receive timely milestone f - [x] The automatic boss bar disappears after a configurable number of seconds. - [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] Disabling automatic boss bars does not prevent `/basesettings status` 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. diff --git a/design/user-stories/us-020-control-base-through-dialog-ui.md b/design/user-stories/us-020-control-base-through-dialog-ui.md new file mode 100644 index 0000000..7981b9b --- /dev/null +++ b/design/user-stories/us-020-control-base-through-dialog-ui.md @@ -0,0 +1,31 @@ +--- +type: User Story +title: "US-020: Control a base through a dialog UI" +description: Let players inspect and control their base through Minecraft's native dialog interface. +status: done +--- + +# US-020: Control a base through a dialog UI + +As a **player**, I want a dialog-based base dashboard so that I can inspect progression and manage my base without remembering command arguments. + +## Acceptance criteria + +- [x] `/basesettings` and `/basesettings ui` open the base-control dialog. +- [x] `/homesettings` and `/homesettings ui` behave identically. +- [x] `/basesettings status` retains the chat-based progression report. +- [x] The dialog summarizes current Base and Pocket Base progression. +- [x] Dialog controls cover visitors, navigation, flight, border, spawnable overlay, boss bar, Pocket Base mob spawning, upgrades, and biome selection when applicable. +- [x] Controls indicate their current or locked state. +- [x] Purchases require confirmation and continue using existing validation, payment, and persistence logic. +- [x] `ui` is offered through command autocomplete. +- [x] Non-player execution remains safely rejected. +- [x] Automated tests verify dialog routing and generated controls. + +## Related + +- [US-008: Unlock Base IV visitor access](us-008-unlock-visitor-access.md) +- [US-009: View progression and unlock notifications](us-009-view-progression-and-notifications.md) +- [US-015: Unlock a Pocket Base](us-015-unlock-a-pocket-base.md) +- [US-018: Control Pocket Base mob spawning](us-018-control-pocket-base-mob-spawning.md) +- [US-019: Change a Pocket Base biome](us-019-change-a-pocket-base-biome.md) diff --git a/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java index e121583..d2fe0f9 100644 --- a/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java +++ b/src/main/java/games/dmg/spigotbase/BaseSettingsCommand.java @@ -15,7 +15,7 @@ import org.bukkit.inventory.PlayerInventory; final class BaseSettingsCommand implements CommandExecutor, TabCompleter { private static final List SETTINGS = List.of( - "status", "upgrade", "pocket", "visitors", "navigation", "flight", "border", + "ui", "status", "upgrade", "pocket", "visitors", "navigation", "flight", "border", "spawnable", "bossbar" ); private static final List VISITOR_MODES = List.of("allowed", "blocked"); @@ -58,7 +58,11 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter { } PlayerState state = stateManager.player(player.getUniqueId(), player.getName()); if (arguments.length == 0 - || arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) { + || arguments.length == 1 && arguments[0].equalsIgnoreCase("ui")) { + showDialog(player, state); + return true; + } + if (arguments.length == 1 && arguments[0].equalsIgnoreCase("status")) { showStatus(player, state); return true; } @@ -441,6 +445,13 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter { return true; } + private void showDialog(Player player, PlayerState state) { + PocketBaseState pocket = pocketBases == null + ? PocketBaseState.locked(state.playerId()) + : pocketBases.state(state.playerId()); + player.showDialog(new BaseSettingsDialogFactory(settings.current()).create(state, pocket)); + } + private void showStatus(Player player, PlayerState state) { player.sendMessage(ChatColor.GOLD + "=== Base Progress ==="); showBasePath(player, state); @@ -663,7 +674,7 @@ final class BaseSettingsCommand implements CommandExecutor, TabCompleter { private static void sendUsage(Player player) { player.sendMessage(ChatColor.RED + "Usage: /basesettings " - + "[status|upgrade|pocket upgrade|pocket mobs " + + "[ui|status|upgrade|pocket upgrade|pocket mobs " + " " + "|pocket type " + "|visitors |navigation " diff --git a/src/main/java/games/dmg/spigotbase/BaseSettingsDialogFactory.java b/src/main/java/games/dmg/spigotbase/BaseSettingsDialogFactory.java new file mode 100644 index 0000000..99c8f80 --- /dev/null +++ b/src/main/java/games/dmg/spigotbase/BaseSettingsDialogFactory.java @@ -0,0 +1,353 @@ +package games.dmg.spigotbase; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.dialog.ConfirmationDialog; +import net.md_5.bungee.api.dialog.Dialog; +import net.md_5.bungee.api.dialog.DialogBase; +import net.md_5.bungee.api.dialog.DialogListDialog; +import net.md_5.bungee.api.dialog.MultiActionDialog; +import net.md_5.bungee.api.dialog.NoticeDialog; +import net.md_5.bungee.api.dialog.action.ActionButton; +import net.md_5.bungee.api.dialog.action.RunCommandAction; +import net.md_5.bungee.api.dialog.body.PlainMessageBody; + +final class BaseSettingsDialogFactory { + private static final int DIALOG_WIDTH = 420; + private final PluginSettings settings; + + BaseSettingsDialogFactory(PluginSettings settings) { + this.settings = settings; + } + + Dialog create(PlayerState owner, PocketBaseState pocket) { + return new DialogListDialog( + base( + "Base Settings", + "Base Settings", + "Base " + owner.baseLevel() + "/4 • Size " + owner.sizeLevel() + "/3" + + " • Flight " + owner.flightLevel() + "/3\n" + + "Pocket Base " + pocket.level() + " • " + + humanize(pocket.biome().commandName()) + ), + List.of( + generalSettings(owner), + progressReport(), + baseUpgrade(owner), + pocketSettings(owner, pocket) + ), + null, + 2, + 190 + ); + } + + private Dialog generalSettings(PlayerState owner) { + return new MultiActionDialog( + base( + "Base Controls", + "Base Controls", + "Select a setting to change it. Locked controls remain visible and explain " + + "their requirement in chat when selected." + ), + List.of( + toggle( + "Visitors", + owner.baseLevel() >= 4, + owner.visitorsEnabled(), + "Allowed", + "Blocked", + "basesettings visitors " + + (owner.visitorsEnabled() ? "blocked" : "allowed") + ), + toggle( + "Navigation", + owner.baseLevel() >= 2, + owner.navigationEnabled(), + "Enabled", + "Disabled", + "basesettings navigation " + + mode(!owner.navigationEnabled()) + ), + toggle( + "Flight", + owner.flightLevel() >= 1, + owner.flightEnabled(), + "Enabled", + "Disabled", + "basesettings flight " + mode(!owner.flightEnabled()) + ), + toggle( + "Border", + owner.baseLevel() >= 1 && owner.base().isPresent(), + owner.borderEnabled(), + "Enabled", + "Disabled", + "basesettings border " + mode(!owner.borderEnabled()) + ), + toggle( + "Spawnable Overlay", + owner.baseLevel() >= 1 + && owner.base().isPresent() + && owner.totalBlocksPlaced() + >= settings.spawnableOverlayUnlockPlacements(), + owner.spawnableOverlayEnabled(), + "Enabled", + "Disabled", + "basesettings spawnable " + mode(!owner.spawnableOverlayEnabled()) + ), + toggle( + "Progress Boss Bar", + true, + owner.bossBarEnabled(), + "Enabled", + "Disabled", + "basesettings bossbar " + mode(!owner.bossBarEnabled()) + ) + ), + 2, + null + ); + } + + private Dialog progressReport() { + return new NoticeDialog(base( + "Progress Report", + "Progress Report", + "Display the detailed progression report in chat." + )).action(commandButton("Show in Chat", "basesettings status")); + } + + private Dialog baseUpgrade(PlayerState owner) { + if (owner.baseLevel() >= 4) { + return notice("Base IV", "Base IV and visitor access are already unlocked."); + } + if (owner.baseLevel() < 3 || owner.base().isEmpty()) { + return notice( + "Base IV — Locked", + "Unlock and establish Base III before purchasing Base IV visitor access." + ); + } + int price = settings.visitorUnlockDiamondCost(); + return confirmation( + "Unlock Base IV", + "Purchase visitor access for " + price + " diamonds?", + "basesettings upgrade" + ); + } + + private Dialog pocketSettings(PlayerState owner, PocketBaseState pocket) { + return new DialogListDialog( + base( + "Pocket Base", + "Pocket Base " + pocket.level(), + pocket.level() == 0 + ? "Pocket Base is locked." + : "Biome: " + pocket.biome().worldType().commandName() + "/" + + pocket.biome().commandName() + "\nHostile mobs: " + + enabled(pocket.hostileMobSpawningEnabled()) + " • Passive mobs: " + + enabled(pocket.passiveMobSpawningEnabled()) + ), + List.of( + pocketUpgrade(owner, pocket), + pocketMobSettings(pocket), + pocketBiomeSettings(pocket) + ), + null, + 1, + 260 + ); + } + + private Dialog pocketUpgrade(PlayerState owner, PocketBaseState pocket) { + if (owner.baseLevel() < 4 || owner.base().isEmpty()) { + return notice( + "Pocket Upgrade — Locked", + "Establish Base IV before purchasing a Pocket Base." + ); + } + int price = pocket.level() == 0 + ? settings.pocketBaseUnlockCost() + : settings.pocketBaseUpgradeCost(); + String currency = humanize(settings.pocketBaseCurrencyMaterial()); + String title = pocket.level() == 0 + ? "Unlock Pocket Base I" + : "Upgrade to Pocket Base " + (pocket.level() + 1); + return confirmation( + title, + "Purchase this upgrade for " + price + " " + currency + "?", + "basesettings pocket upgrade" + ); + } + + private Dialog pocketMobSettings(PocketBaseState pocket) { + if (pocket.level() < 1) { + return notice( + "Mob Spawning — Locked", + "Unlock Pocket Base I to control natural mob spawning." + ); + } + return new MultiActionDialog( + base( + "Mob Spawning", + "Mob Spawning", + "Control natural spawning independently for each mob category." + ), + List.of( + toggle( + "Hostile Mobs", + true, + pocket.hostileMobSpawningEnabled(), + "Enabled", + "Disabled", + "basesettings pocket mobs hostile " + + mode(!pocket.hostileMobSpawningEnabled()) + ), + toggle( + "Passive Mobs", + true, + pocket.passiveMobSpawningEnabled(), + "Enabled", + "Disabled", + "basesettings pocket mobs passive " + + mode(!pocket.passiveMobSpawningEnabled()) + ) + ), + 1, + null + ); + } + + private Dialog pocketBiomeSettings(PocketBaseState pocket) { + if (pocket.level() < 1) { + return notice( + "Change Biome — Locked", + "Unlock Pocket Base I to purchase biome changes." + ); + } + List worldTypes = Arrays.stream(PocketBaseWorldType.values()) + .map(type -> pocketBiomeType(pocket, type)) + .map(Dialog.class::cast) + .toList(); + return new DialogListDialog( + base( + "Change Biome", + "Change Biome", + "Current biome: " + pocket.biome().worldType().commandName() + "/" + + pocket.biome().commandName() + "\nEach change costs " + + settings.pocketBaseBiomeChangeCost() + " " + + humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "." + ), + worldTypes, + null, + 1, + 220 + ); + } + + private DialogListDialog pocketBiomeType( + PocketBaseState pocket, + PocketBaseWorldType worldType + ) { + List biomes = Arrays.stream(PocketBaseBiome.values()) + .filter(biome -> biome.worldType() == worldType) + .map(biome -> biomeConfirmation(pocket, biome)) + .map(Dialog.class::cast) + .toList(); + return new DialogListDialog( + base( + humanize(worldType.commandName()), + humanize(worldType.commandName()) + " Biomes", + "Choose a biome subtype. The active biome is marked as selected." + ), + biomes, + null, + 2, + 190 + ); + } + + private Dialog biomeConfirmation(PocketBaseState pocket, PocketBaseBiome biome) { + String title = humanize(biome.commandName()) + + (pocket.biome() == biome ? " — Selected" : ""); + if (pocket.biome() == biome) { + return notice(title, "This is your Pocket Base's current biome."); + } + return confirmation( + title, + "Change the Pocket Base biome for " + settings.pocketBaseBiomeChangeCost() + + " " + humanize(settings.pocketBaseBiomeCurrencyMaterial()) + "?", + "basesettings pocket type " + biome.worldType().commandName() + " " + + biome.commandName() + ); + } + + private static Dialog confirmation(String title, String message, String command) { + return new ConfirmationDialog(base(title, title, message)) + .yes(commandButton("Confirm", command)); + } + + private static Dialog notice(String title, String message) { + return new NoticeDialog(base(title, title, message)); + } + + private static ActionButton toggle( + String name, + boolean unlocked, + boolean active, + String activeLabel, + String inactiveLabel, + String command + ) { + String state = unlocked ? (active ? activeLabel : inactiveLabel) : "Locked"; + String tooltip = unlocked + ? "Click to change this setting." + : "This setting has not been unlocked yet."; + return new ActionButton( + text(name + ": " + state), + text(tooltip), + 190, + new RunCommandAction("/" + command) + ); + } + + private static ActionButton commandButton(String label, String command) { + return new ActionButton(text(label), new RunCommandAction("/" + command)); + } + + private static DialogBase base(String title, String externalTitle, String message) { + return new DialogBase(text(title)) + .externalTitle(text(externalTitle)) + .body(List.of(new PlainMessageBody(text(message), DIALOG_WIDTH))) + .canCloseWithEscape(true) + .pause(false) + .afterAction(DialogBase.AfterAction.CLOSE); + } + + private static BaseComponent text(String value) { + return new TextComponent(value); + } + + private static String mode(boolean active) { + return active ? "enable" : "disable"; + } + + private static String enabled(boolean active) { + return active ? "enabled" : "disabled"; + } + + private static String humanize(String value) { + String normalized = value.toLowerCase(Locale.ROOT).replace('_', ' '); + StringBuilder result = new StringBuilder(normalized.length()); + boolean capitalize = true; + for (int index = 0; index < normalized.length(); index++) { + char character = normalized.charAt(index); + result.append(capitalize ? Character.toUpperCase(character) : character); + capitalize = character == ' '; + } + return result.toString(); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index ee84320..72b606d 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -19,7 +19,7 @@ commands: aliases: [visit] basesettings: description: View progression, upgrade, and manage base settings. - usage: /basesettings [status|upgrade|pocket|visitors|navigation|flight|border|spawnable|bossbar] + usage: /basesettings [ui|status|upgrade|pocket|visitors|navigation|flight|border|spawnable|bossbar] aliases: [homesettings] baseadmin: description: Administer Spigot Base. diff --git a/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java index 84453da..4f0f350 100644 --- a/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java +++ b/src/test/java/games/dmg/spigotbase/BaseSettingsCommandTest.java @@ -20,13 +20,29 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.UnaryOperator; import org.bukkit.ChatColor; import org.bukkit.Material; +import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.PlayerInventory; +import net.md_5.bungee.api.dialog.Dialog; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; final class BaseSettingsCommandTest { + @Test + void rejectsNonPlayerExecution() { + CommandSender sender = mock(CommandSender.class); + BaseSettingsCommand command = new BaseSettingsCommand( + mock(BaseStateManager.class), + new PluginSettingsProvider(PluginSettings.from(Map.of())), + mock(BaseFlightController.class) + ); + + command.onCommand(sender, null, "basesettings", new String[0]); + + verify(sender).sendMessage("Only players have base progression."); + } + @Test void spawnableEnableIsIdempotentAfterUnlock() { UUID playerId = UUID.randomUUID(); @@ -156,8 +172,8 @@ final class BaseSettingsCommandTest { assertEquals( List.of( - "status", "upgrade", "pocket", "visitors", "navigation", "flight", "border", - "spawnable", "bossbar" + "ui", "status", "upgrade", "pocket", "visitors", "navigation", "flight", + "border", "spawnable", "bossbar" ), command.onTabComplete(null, null, "basesettings", new String[] {""}) ); @@ -526,7 +542,7 @@ final class BaseSettingsCommandTest { } @Test - void defaultAndStatusDisplayTheFullProgressReport() { + void defaultAndUiOpenDialogWhileStatusDisplaysTheFullProgressReport() { UUID playerId = UUID.randomUUID(); Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(playerId); @@ -542,17 +558,19 @@ final class BaseSettingsCommandTest { ); command.onCommand(player, null, "basesettings", new String[0]); + command.onCommand(player, null, "homesettings", new String[] {"ui"}); command.onCommand(player, null, "basesettings", new String[] {"status"}); - verify(player, times(2)).sendMessage(ChatColor.GOLD + "=== Base Progress ==="); - verify(player, times(2)).sendMessage( + verify(player, times(2)).showDialog(any(Dialog.class)); + verify(player).sendMessage(ChatColor.GOLD + "=== Base Progress ==="); + verify(player).sendMessage( org.mockito.ArgumentMatchers.argThat(message -> message.contains("Spawnable Overlay:") && message.contains("0/250 placements") && message.contains("locked") ) ); - verify(player, times(2)).sendMessage( + verify(player).sendMessage( org.mockito.ArgumentMatchers.argThat(message -> message.contains("Settings: visitors=") && message.contains("navigation=") diff --git a/src/test/java/games/dmg/spigotbase/BaseSettingsDialogFactoryTest.java b/src/test/java/games/dmg/spigotbase/BaseSettingsDialogFactoryTest.java new file mode 100644 index 0000000..e087266 --- /dev/null +++ b/src/test/java/games/dmg/spigotbase/BaseSettingsDialogFactoryTest.java @@ -0,0 +1,152 @@ +package games.dmg.spigotbase; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import net.md_5.bungee.api.dialog.ConfirmationDialog; +import net.md_5.bungee.api.dialog.Dialog; +import net.md_5.bungee.api.dialog.DialogListDialog; +import net.md_5.bungee.api.dialog.MultiActionDialog; +import net.md_5.bungee.api.dialog.NoticeDialog; +import net.md_5.bungee.api.dialog.action.Action; +import net.md_5.bungee.api.dialog.action.ActionButton; +import net.md_5.bungee.api.dialog.action.RunCommandAction; +import org.junit.jupiter.api.Test; + +final class BaseSettingsDialogFactoryTest { + @Test + void createsControlsForEveryBaseSettingAndConfirmedPurchase() { + UUID playerId = UUID.randomUUID(); + PlayerState owner = PlayerState.newPlayer(playerId, "Builder") + .withAdministrativeLevels(4, 2, 2, 1, 1, true, true, true) + .withBase( + new BaseLocation(UUID.randomUUID(), "world", 10, 64, 20, 10, 20), + Instant.EPOCH + ) + .withBorderEnabled(true) + .withTotalBlocksPlaced(250) + .withSpawnableOverlayEnabled(true); + PocketBaseState pocket = new PocketBaseState( + playerId, + 1, + Optional.empty(), + Optional.empty(), + true, + false, + PocketBaseBiome.PLAINS + ); + + Dialog dialog = new BaseSettingsDialogFactory( + PluginSettings.from(Map.of()) + ).create(owner, pocket); + + DialogListDialog root = assertInstanceOf(DialogListDialog.class, dialog); + assertTrue(root.getBase().body().get(0).toString().contains("Base 4/4")); + List commands = commands(root); + assertTrue(commands.contains("/basesettings status")); + assertTrue(commands.contains("/basesettings visitors blocked")); + assertTrue(commands.contains("/basesettings navigation disable")); + assertTrue(commands.contains("/basesettings flight disable")); + assertTrue(commands.contains("/basesettings border disable")); + assertTrue(commands.contains("/basesettings spawnable disable")); + assertTrue(commands.contains("/basesettings bossbar disable")); + assertTrue(commands.contains("/basesettings pocket upgrade")); + assertTrue(commands.contains("/basesettings pocket mobs hostile disable")); + assertTrue(commands.contains("/basesettings pocket mobs passive enable")); + assertTrue(commands.contains("/basesettings pocket type nether crimson_forest")); + } + + @Test + void requiresConfirmationForBaseIvPurchase() { + UUID playerId = UUID.randomUUID(); + PlayerState owner = PlayerState.newPlayer(playerId, "Builder") + .withAdministrativeLevels(3, 0, 0, 0, 0, false, false, false) + .withBase( + new BaseLocation(UUID.randomUUID(), "world", 0, 64, 0, 0, 0), + Instant.EPOCH + ); + + DialogListDialog root = assertInstanceOf( + DialogListDialog.class, + new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).create( + owner, + PocketBaseState.locked(playerId) + ) + ); + ConfirmationDialog upgrade = assertInstanceOf( + ConfirmationDialog.class, + root.dialogs().get(2) + ); + + RunCommandAction action = assertInstanceOf( + RunCommandAction.class, + upgrade.yes().action() + ); + assertTrue(action.template().equals("/basesettings upgrade")); + } + + @Test + void marksUnavailableControlsAsLocked() { + UUID playerId = UUID.randomUUID(); + PlayerState owner = PlayerState.newPlayer(playerId, "Newcomer"); + + DialogListDialog root = assertInstanceOf( + DialogListDialog.class, + new BaseSettingsDialogFactory(PluginSettings.from(Map.of())).create( + owner, + PocketBaseState.locked(playerId) + ) + ); + MultiActionDialog controls = assertInstanceOf( + MultiActionDialog.class, + root.dialogs().get(0) + ); + List labels = controls.actions().stream() + .map(ActionButton::label) + .map(Object::toString) + .toList(); + + assertTrue(labels.stream().anyMatch(label -> label.contains("Visitors: Locked"))); + assertTrue(labels.stream().anyMatch(label -> label.contains("Navigation: Locked"))); + assertTrue(labels.stream().anyMatch(label -> label.contains("Flight: Locked"))); + assertTrue(labels.stream().anyMatch(label -> label.contains("Border: Locked"))); + assertTrue(labels.stream().anyMatch(label -> label.contains("Spawnable Overlay: Locked"))); + } + + private static List commands(Dialog dialog) { + List commands = new ArrayList<>(); + collect(dialog, commands); + return commands; + } + + private static void collect(Dialog dialog, List commands) { + if (dialog instanceof DialogListDialog list) { + list.dialogs().forEach(child -> collect(child, commands)); + collect(list.exitAction(), commands); + } else if (dialog instanceof MultiActionDialog actions) { + actions.actions().forEach(button -> collect(button, commands)); + collect(actions.exitAction(), commands); + } else if (dialog instanceof ConfirmationDialog confirmation) { + collect(confirmation.yes(), commands); + collect(confirmation.no(), commands); + } else if (dialog instanceof NoticeDialog notice) { + collect(notice.action(), commands); + } + } + + private static void collect(ActionButton button, List commands) { + if (button == null) { + return; + } + Action action = button.action(); + if (action instanceof RunCommandAction command) { + commands.add(command.template()); + } + } +}