From 7c67722ba0ad3aa3047a5a9bbebb8809f27ca3e5 Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 26 Sep 2026 17:56:27 +0100 Subject: [PATCH] feat: settings panel improvements from #3092 - Hide the vanilla "Interact with Spawn Egg: Sets Mob Type" tooltip on spawner icons by adding minecraft:block_entity_data to the hidden tooltip components of every panel item. Paper has no constant for it, so it is looked up in the data component registry. - Command Ranks no longer lists sub-commands the viewer has no permission for (e.g. /ob border when it is denied). Top-level commands were already filtered; the recursive walk into sub-commands was not. - The settings panel remembers the Basic/Advanced/Expert mode again, and one mode is shared by the Protection and Settings tabs. It is stored in the existing Players.flagsDisplayMode field, which went unused after #2343, and saved only when the player changes it. A tab that has nothing to show in the chosen mode still skips ahead for display, without changing the saved choice. The admin panel keeps opening in Expert and does not touch the admin's preference. Co-Authored-By: Claude Opus 5.5 --- .../bentobox/api/panels/PanelItem.java | 26 +++++++++- .../bentobox/database/objects/Players.java | 18 +++++++ .../CommandRankClickListener.java | 15 ++++-- .../bentobox/managers/PlayersManager.java | 27 ++++++++++ .../panels/customizable/SettingsPanel.java | 30 ++++++++--- .../island/IslandSettingsCommandTest.java | 2 + .../CommandRankClickListenerTest.java | 29 +++++++++++ .../bentobox/managers/PlayersManagerTest.java | 12 +++++ .../customizable/SettingsPanelTest.java | 52 ++++++++++++++++++- 9 files changed, 197 insertions(+), 14 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/api/panels/PanelItem.java b/src/main/java/world/bentobox/bentobox/api/panels/PanelItem.java index 8afe5a7b8..d2a75aa1d 100644 --- a/src/main/java/world/bentobox/bentobox/api/panels/PanelItem.java +++ b/src/main/java/world/bentobox/bentobox/api/panels/PanelItem.java @@ -4,16 +4,20 @@ import java.util.List; import java.util.Optional; +import io.papermc.paper.datacomponent.DataComponentType; import io.papermc.paper.datacomponent.DataComponentTypes; import io.papermc.paper.datacomponent.item.TooltipDisplay; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; import org.bukkit.enchantments.Enchantment; import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.ItemFlag; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import org.eclipse.jdt.annotation.Nullable; import world.bentobox.bentobox.api.panels.builders.PanelItemBuilder; import world.bentobox.bentobox.api.user.User; @@ -269,7 +273,7 @@ private void hideAdditionalTooltip() { if (existing != null) { builder.hideTooltip(existing.hideTooltip()); builder.addHiddenComponents( - existing.hiddenComponents().toArray(io.papermc.paper.datacomponent.DataComponentType[]::new)); + existing.hiddenComponents().toArray(DataComponentType[]::new)); } // Hide additional components that don't have non-deprecated ItemFlag equivalents builder.addHiddenComponents( @@ -282,6 +286,26 @@ private void hideAdditionalTooltip() { DataComponentTypes.BUNDLE_CONTENTS, DataComponentTypes.MAP_DECORATIONS ); + DataComponentType blockEntityData = blockEntityData(); + if (blockEntityData != null) { + builder.addHiddenComponents(blockEntityData); + } icon.setData(DataComponentTypes.TOOLTIP_DISPLAY, builder.build()); } + + /** + * Hiding this component drops the "Interact with Spawn Egg: Sets Mob Type" lines that a + * spawner or trial spawner icon otherwise shows (#3092). Paper has no constant for it because + * it has no API value, so it is looked up in the registry. + * @return the {@code minecraft:block_entity_data} component type, or null if unavailable + */ + @Nullable + private static DataComponentType blockEntityData() { + try { + return Registry.DATA_COMPONENT_TYPE.get(NamespacedKey.minecraft("block_entity_data")); + } catch (RuntimeException e) { + // Registry not available, e.g., in a test environment + return null; + } + } } diff --git a/src/main/java/world/bentobox/bentobox/database/objects/Players.java b/src/main/java/world/bentobox/bentobox/database/objects/Players.java index 410a7eac7..b2d65209d 100644 --- a/src/main/java/world/bentobox/bentobox/database/objects/Players.java +++ b/src/main/java/world/bentobox/bentobox/database/objects/Players.java @@ -296,4 +296,22 @@ public void setLastLogin(Long lastLogin) { this.lastLogin = lastLogin; } + /** + * @return the display mode the player last chose in the settings panel, never + * {@link Flag.Mode#TOP_ROW} + * @since 3.23.2 + */ + public Flag.Mode getFlagsDisplayMode() { + return flagsDisplayMode == null || flagsDisplayMode == Flag.Mode.TOP_ROW ? Flag.Mode.BASIC + : flagsDisplayMode; + } + + /** + * @param flagsDisplayMode the display mode to show the settings panel in + * @since 3.23.2 + */ + public void setFlagsDisplayMode(Flag.Mode flagsDisplayMode) { + this.flagsDisplayMode = flagsDisplayMode; + } + } diff --git a/src/main/java/world/bentobox/bentobox/listeners/flags/clicklisteners/CommandRankClickListener.java b/src/main/java/world/bentobox/bentobox/listeners/flags/clicklisteners/CommandRankClickListener.java index aaf990ddf..9f6ecde8b 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/flags/clicklisteners/CommandRankClickListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/flags/clicklisteners/CommandRankClickListener.java @@ -150,25 +150,32 @@ private List getCommands(World world, User user) { return plugin.getCommandsManager().getCommands().values().stream() .filter(c -> c.getWorld() != null && c.getWorld().equals(world)) // Only allow commands in this world .filter(c -> c.testPermission(user.getSender())) // Only allow them to see commands they have permission to see - .flatMap(c -> getCmdRecursively("/", c).stream()) + .flatMap(c -> getCmdRecursively("/", c, user).stream()) .filter(label -> user.isOp() || !hiddenItems.contains(CommandCycleClick.COMMAND_RANK_PREFIX + label)) // Hide any hidden commands .limit(49) // Silently limit to 49 .toList(); } /** - * Recursively traverses the command tree looking for any configurable rank command and returns a string list of commands + * Recursively traverses the command tree looking for any configurable rank command and returns a string list of commands. + * Commands the user has no permission for are skipped, along with their sub-commands, because setting a rank for a + * command nobody on the island can run is pointless (#3092). * @param labels - preceding command's label list * @param cc - composite command + * @param user - the user viewing the panel * @return string list of commands */ - private List getCmdRecursively(String labels, CompositeCommand cc) { + private List getCmdRecursively(String labels, CompositeCommand cc, User user) { List result = new ArrayList<>(); + if (!user.isOp() && cc.getPermission() != null && !cc.getPermission().isEmpty() + && !user.hasPermission(cc.getPermission())) { + return result; + } String newLabel = labels + cc.getName(); if (cc.isConfigurableRankCommand()) { result.add(newLabel); } - cc.getSubCommands().values().forEach(s -> result.addAll(getCmdRecursively(newLabel + " ", s))); + cc.getSubCommands().values().forEach(s -> result.addAll(getCmdRecursively(newLabel + " ", s, user))); return result; } diff --git a/src/main/java/world/bentobox/bentobox/managers/PlayersManager.java b/src/main/java/world/bentobox/bentobox/managers/PlayersManager.java index d0fc26f21..05118532c 100644 --- a/src/main/java/world/bentobox/bentobox/managers/PlayersManager.java +++ b/src/main/java/world/bentobox/bentobox/managers/PlayersManager.java @@ -24,6 +24,7 @@ import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.events.player.PlayerEvent; +import world.bentobox.bentobox.api.flags.Flag; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.Database; import world.bentobox.bentobox.database.objects.Island; @@ -349,6 +350,32 @@ public int getDeaths(World world, UUID playerUUID) { return getPlayer(playerUUID).getDeaths(Util.getWorld(world)); } + /** + * Returns the display mode the player last chose in the settings panel, so it opens the + * way they left it. + * @param playerUUID - the player's UUID + * @return the basic, advanced or expert display mode + * @since 3.23.2 + */ + public Flag.Mode getFlagsDisplayMode(UUID playerUUID) { + return getPlayer(playerUUID).getFlagsDisplayMode(); + } + + /** + * Remembers the display mode the player chose in the settings panel. Saves only when it + * changes. + * @param playerUUID - the player's UUID + * @param mode - the basic, advanced or expert display mode + * @since 3.23.2 + */ + public void setFlagsDisplayMode(UUID playerUUID, Flag.Mode mode) { + Players p = getPlayer(playerUUID); + if (p.getFlagsDisplayMode() != mode) { + p.setFlagsDisplayMode(mode); + handler.saveObjectAsync(p); + } + } + /** * Sets if a player is mid-teleport or not * @param uniqueId - unique ID diff --git a/src/main/java/world/bentobox/bentobox/panels/customizable/SettingsPanel.java b/src/main/java/world/bentobox/bentobox/panels/customizable/SettingsPanel.java index eb2fa9c80..a989e7725 100644 --- a/src/main/java/world/bentobox/bentobox/panels/customizable/SettingsPanel.java +++ b/src/main/java/world/bentobox/bentobox/panels/customizable/SettingsPanel.java @@ -172,7 +172,14 @@ public boolean isReadOnly() { @Nullable private final Island island; private final List tabs; - private final Flag.Mode defaultMode; + /** The display mode the viewer chose; shared by every tab. */ + private Flag.Mode selectedMode; + /** Whether a mode change is saved as the viewer's preference for next time. */ + private boolean remembersMode; + /** + * Per-tab overrides of {@link #selectedMode}, set when a tab has nothing to show in the + * chosen mode and skips ahead. + */ private final Map modes = new EnumMap<>(TabType.class); private final Map tabTemplates = new EnumMap<>(TabType.class); /** @@ -198,7 +205,7 @@ public boolean isReadOnly() { * @param world the world the settings are for * @param island the island, or null if the viewer is not on one * @param tabs the tabs to show, in order; the first the viewer may see is shown initially - * @param defaultMode the display mode each tab starts in + * @param defaultMode the display mode the panel starts in */ protected SettingsPanel(@NonNull CompositeCommand command, @NonNull User user, @NonNull String templateName, @NonNull World world, @Nullable Island island, @NonNull List tabs, Flag.Mode defaultMode) { @@ -210,13 +217,14 @@ protected SettingsPanel(@NonNull CompositeCommand command, @NonNull User user, @ this.tabs = tabs.stream() .filter(t -> t.getPermission(prefix).isEmpty() || user.hasPermission(t.getPermission(prefix))) .toList(); - this.defaultMode = defaultMode; + this.selectedMode = defaultMode; this.activeTab = this.tabs.isEmpty() ? tabs.get(0) : this.tabs.get(0); } /** * Opens the settings panel for a player. With an island the protection and settings tabs are - * shown; without one, only the read-only view of the world's protection flags. + * shown; without one, only the read-only view of the world's protection flags. It opens in the + * display mode the player last chose. * @param command the island settings command * @param user the viewer * @param island the island, or null if the viewer is not on one @@ -226,7 +234,10 @@ public static boolean openPanel(@NonNull CompositeCommand command, @NonNull User List tabs = island == null ? List.of(TabType.WORLD_PROTECTION) : List.of(TabType.PROTECTION, TabType.SETTING); World world = island == null ? command.getWorld() : island.getWorld(); - return new SettingsPanel(command, user, SETTINGS_PANEL, world, island, tabs, Mode.BASIC).open(); + SettingsPanel settingsPanel = new SettingsPanel(command, user, SETTINGS_PANEL, world, island, tabs, + command.getPlugin().getPlayers().getFlagsDisplayMode(user.getUniqueId())); + settingsPanel.remembersMode = true; + return settingsPanel.open(); } /** @@ -441,7 +452,7 @@ private List getFlags(Flag.Mode mode) { } private Flag.Mode currentMode() { - return modes.getOrDefault(activeTab, defaultMode); + return modes.getOrDefault(activeTab, selectedMode); } private boolean isVisibleToUser(Flag flag) { @@ -623,7 +634,12 @@ private PanelItem createModeButton(@NonNull ItemTemplateRecord template, Templat user.getTranslation(PROTECTION_PANEL + "mode." + nextKey + ".name"))) .clickHandler((p, u, clickType, s) -> { if (clickAllowed(template, clickType)) { - modes.put(activeTab, currentMode().getNext()); + // One mode for every tab, remembered for the next time the player opens it + selectedMode = currentMode().getNext(); + modes.clear(); + if (remembersMode) { + plugin.getPlayers().setFlagsDisplayMode(u.getUniqueId(), selectedMode); + } pageIndex = 0; u.getPlayer().playSound(u.getLocation(), Sound.BLOCK_STONE_BUTTON_CLICK_OFF, 1F, 1F); } diff --git a/src/test/java/world/bentobox/bentobox/api/commands/island/IslandSettingsCommandTest.java b/src/test/java/world/bentobox/bentobox/api/commands/island/IslandSettingsCommandTest.java index 914686136..5c97ffd6b 100644 --- a/src/test/java/world/bentobox/bentobox/api/commands/island/IslandSettingsCommandTest.java +++ b/src/test/java/world/bentobox/bentobox/api/commands/island/IslandSettingsCommandTest.java @@ -29,6 +29,7 @@ import world.bentobox.bentobox.CommonTestSetup; import world.bentobox.bentobox.Settings; import world.bentobox.bentobox.api.commands.CompositeCommand; +import world.bentobox.bentobox.api.flags.Flag.Mode; import world.bentobox.bentobox.api.flags.Flag.Type; import world.bentobox.bentobox.api.panels.Tab; import world.bentobox.bentobox.api.panels.TabbedPanel; @@ -153,6 +154,7 @@ void testExecuteOpensTemplatedPanel() { when(user.getTranslationOrNothing(anyString())).thenReturn(""); when(im.getIslandAt(any(Location.class))).thenReturn(Optional.of(island)); when(island.getWorld()).thenReturn(world); + when(plugin.getPlayers().getFlagsDisplayMode(any())).thenReturn(Mode.BASIC); isc.canExecute(user, "settings", Collections.emptyList()); assertTrue(isc.execute(user, "settings", Collections.emptyList())); diff --git a/src/test/java/world/bentobox/bentobox/listeners/flags/clicklisteners/CommandRankClickListenerTest.java b/src/test/java/world/bentobox/bentobox/listeners/flags/clicklisteners/CommandRankClickListenerTest.java index c2cec844f..bd172cab7 100644 --- a/src/test/java/world/bentobox/bentobox/listeners/flags/clicklisteners/CommandRankClickListenerTest.java +++ b/src/test/java/world/bentobox/bentobox/listeners/flags/clicklisteners/CommandRankClickListenerTest.java @@ -12,6 +12,7 @@ import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; @@ -197,6 +198,34 @@ void testOnClickTooManyCommands() { "protection.panel.flag-item.command-instructions."); } + /** + * Sub-commands the viewer has no permission for are not listed, so their rank cannot be set (#3092). + * The listed commands are "/test" and "/test allowed", so slot 1 is "/test allowed". + */ + @Test + void testOnClickHidesSubCommandsWithoutPermission() { + when(user.isOp()).thenReturn(false); + when(user.hasPermission("oneblock.island.border")).thenReturn(false); + CompositeCommand cc = cm.getCommands().get("test"); + Map subCommands = new LinkedHashMap<>(); + subCommands.put("border", subCommand("border", "oneblock.island.border")); + subCommands.put("allowed", subCommand("allowed", "oneblock.island.allowed")); + when(cc.getSubCommands()).thenReturn(subCommands); + + assertTrue(crcl.onClick(panel, user, ClickType.LEFT, 1)); + verify(user).getTranslationOrNothing("protection.panel.flag-item.command-instructions.allowed"); + verify(user, never()).getTranslationOrNothing("protection.panel.flag-item.command-instructions.border"); + } + + private CompositeCommand subCommand(String name, String permission) { + CompositeCommand sub = mock(CompositeCommand.class); + when(sub.isConfigurableRankCommand()).thenReturn(true); + when(sub.getName()).thenReturn(name); + when(sub.getPermission()).thenReturn(permission); + when(sub.getSubCommands()).thenReturn(Collections.emptyMap()); + return sub; + } + /** * Test method for {@link world.bentobox.bentobox.listeners.flags.clicklisteners.CommandRankClickListener#getPanelItem(java.lang.String, world.bentobox.bentobox.api.user.User, org.bukkit.World)}. */ diff --git a/src/test/java/world/bentobox/bentobox/managers/PlayersManagerTest.java b/src/test/java/world/bentobox/bentobox/managers/PlayersManagerTest.java index 6770cd5ff..2f1252cbd 100644 --- a/src/test/java/world/bentobox/bentobox/managers/PlayersManagerTest.java +++ b/src/test/java/world/bentobox/bentobox/managers/PlayersManagerTest.java @@ -54,6 +54,7 @@ import org.mockito.Mockito; import world.bentobox.bentobox.CommonTestSetup; +import world.bentobox.bentobox.api.flags.Flag; import world.bentobox.bentobox.api.user.User; import world.bentobox.bentobox.database.AbstractDatabaseHandler; import world.bentobox.bentobox.database.Database; @@ -687,6 +688,17 @@ void testSetLocale() { assertEquals("en-UK", pm.getLocale(uuid)); } + /** + * Test method for + * {@link world.bentobox.bentobox.managers.PlayersManager#setFlagsDisplayMode(java.util.UUID, world.bentobox.bentobox.api.flags.Flag.Mode)}. + */ + @Test + void testFlagsDisplayMode() { + assertEquals(Flag.Mode.BASIC, pm.getFlagsDisplayMode(uuid)); + pm.setFlagsDisplayMode(uuid, Flag.Mode.EXPERT); + assertEquals(Flag.Mode.EXPERT, pm.getFlagsDisplayMode(uuid)); + } + /** * Test method for * {@link world.bentobox.bentobox.managers.PlayersManager#setPlayerName(world.bentobox.bentobox.api.user.User)}. diff --git a/src/test/java/world/bentobox/bentobox/panels/customizable/SettingsPanelTest.java b/src/test/java/world/bentobox/bentobox/panels/customizable/SettingsPanelTest.java index 724a7c99c..e4a2281ee 100644 --- a/src/test/java/world/bentobox/bentobox/panels/customizable/SettingsPanelTest.java +++ b/src/test/java/world/bentobox/bentobox/panels/customizable/SettingsPanelTest.java @@ -118,6 +118,7 @@ public void setUp() throws Exception { return flags.stream().filter(f -> f.getID().equals(id)).findFirst(); }); when(iwm.getHiddenFlags(any())).thenReturn(hidden); + when(plugin.getPlayers().getFlagsDisplayMode(any())).thenReturn(Mode.BASIC); // Command when(command.getPlugin()).thenReturn(plugin); @@ -330,15 +331,49 @@ void testModeCycling() { } /** - * The mode is remembered per tab. + * One mode is shared by every tab (#3092). */ @Test - void testModePerTab() { + void testModeSharedAcrossTabs() { SettingsPanel sp = openWithIsland(); click(items(sp), MODE, user, ClickType.LEFT); assertEquals(Mode.ADVANCED, sp.getMode()); click(items(sp), SETTING_TAB, user, ClickType.LEFT); + assertEquals(Mode.ADVANCED, sp.getMode()); + } + + /** + * The panel opens in the mode the player last chose, and a change is saved for next time (#3092). + */ + @Test + void testModeRemembered() { + when(plugin.getPlayers().getFlagsDisplayMode(uuid)).thenReturn(Mode.EXPERT); + SettingsPanel sp = openWithIsland(); + assertEquals(Mode.EXPERT, sp.getMode()); + assertEquals(Material.NETHER_BRICK, material(items(sp), MODE)); + + click(items(sp), MODE, user, ClickType.LEFT); assertEquals(Mode.BASIC, sp.getMode()); + verify(plugin.getPlayers()).setFlagsDisplayMode(uuid, Mode.BASIC); + } + + /** + * A tab with nothing to show in the chosen mode skips ahead for display only; the player's + * choice is neither changed nor saved. + */ + @Test + void testSkippedModeNotSaved() { + when(iwm.getAddon(any())).thenReturn(Optional.of(gma)); + flags.removeIf(f -> f.getID().equals("S_GAMMA")); + flags.add(flag("S_ADVANCED", Type.SETTING, Mode.ADVANCED)); + SettingsPanel sp = openWithIsland(); + click(items(sp), SETTING_TAB, user, ClickType.LEFT); + sp.refreshPanel(); + assertEquals(Mode.ADVANCED, sp.getMode()); + click(items(sp), PROTECTION_TAB, user, ClickType.LEFT); + sp.refreshPanel(); + assertEquals(Mode.BASIC, sp.getMode()); + verify(plugin.getPlayers(), never()).setFlagsDisplayMode(any(), any()); } /** @@ -493,6 +528,19 @@ void testUnknownPinnedFlagIsSkipped(@TempDir Path addonFolder) throws IOExceptio // Section: Admin panel // --------------------------------------------------------------------- + /** + * The admin panel always opens in expert mode and does not touch the admin's own preference. + */ + @Test + void testAdminModeNotRemembered() { + SettingsPanel sp = openAdmin(island); + assertEquals(Mode.EXPERT, sp.getMode()); + click(items(sp), MODE, user, ClickType.LEFT); + assertEquals(Mode.BASIC, sp.getMode()); + verify(plugin.getPlayers(), never()).getFlagsDisplayMode(any()); + verify(plugin.getPlayers(), never()).setFlagsDisplayMode(any(), any()); + } + private SettingsPanel openAdmin(Island target) { assertTrue(SettingsPanel.openAdminPanel(command, user, target)); return (SettingsPanel) PanelListenerManager.getOpenPanels().get(uuid).getListener().orElseThrow();