diff --git a/src/main/java/net/blueva/menu/Main.java b/src/main/java/net/blueva/menu/Main.java index b1489ad..98fa89e 100644 --- a/src/main/java/net/blueva/menu/Main.java +++ b/src/main/java/net/blueva/menu/Main.java @@ -147,6 +147,11 @@ public void onDisable() { if (menuSyncService != null) { menuSyncService.shutdown(); } + // Cancel running animations and drop per-player menu state + if (javaMenuManager != null) { + javaMenuManager.shutdown(); + } + net.blueva.menu.managers.java.PlayerManager.clearAll(); // Disconnect web editor if (webEditorManager != null) { webEditorManager.disconnect(); @@ -167,6 +172,19 @@ public void onDisable() { Bukkit.getConsoleSender().sendMessage(ChatColor.RED + "V. " + pluginversion + " | Plugin disabled successfully | blueva.net"); } + /** + * Full config + menu reload, shared by {@code /bm reload} and the web editor so the two + * can never drift apart. Must run on the main server thread. + *

Note: {@code webeditor.*} and {@code metrics} still need a full restart. + */ + public void reloadAll() { + configManager.reloadSettings(); + configManager.reloadLang(); + menuSyncService.reload(); + javaMenuManager.loadJavaMenus(); + bedrockMenuManager.loadBedrockMenus(); + } + public void registerCommands() { CommandHandler handler = new CommandHandler(this); diff --git a/src/main/java/net/blueva/menu/commands/main/subcommands/ReloadSubCommand.java b/src/main/java/net/blueva/menu/commands/main/subcommands/ReloadSubCommand.java index 0e54bd5..5256e2f 100644 --- a/src/main/java/net/blueva/menu/commands/main/subcommands/ReloadSubCommand.java +++ b/src/main/java/net/blueva/menu/commands/main/subcommands/ReloadSubCommand.java @@ -2,13 +2,9 @@ import net.blueva.menu.Main; import net.blueva.menu.commands.CommandInterface; -import net.blueva.menu.managers.java.AnimationManager; import net.blueva.menu.utils.MessagesUtil; -import org.bukkit.Bukkit; -import org.bukkit.ChatColor; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; public class ReloadSubCommand implements CommandInterface @@ -24,13 +20,7 @@ public ReloadSubCommand(Main main) { public boolean onCommand(@NotNull CommandSender sender, Command cmd, String commandLabel, String @NotNull [] args) { if(sender.hasPermission("bluemenu.reload")) { - main.getConfigManager().reloadSettings(); - main.configManager.reloadLang(); - main.getMenuSyncService().reload(); - main.javaMenuManager.loadJavaMenus(); - if(Main.isUsingFloodgate) { - main.bedrockMenuManager.loadBedrockMenus(); - } + main.reloadAll(); MessagesUtil.sendMessage(sender, main.configManager.getLang().getString("commands.bluemenu.reload.success")); } else { MessagesUtil.sendMessage(sender, main.configManager.getLang().getString("commands.bluemenu.reload.insufficient_permissions")); diff --git a/src/main/java/net/blueva/menu/common/dto/MenuMetadataDTO.java b/src/main/java/net/blueva/menu/common/dto/MenuMetadataDTO.java index fc9f76f..1ccd8a6 100644 --- a/src/main/java/net/blueva/menu/common/dto/MenuMetadataDTO.java +++ b/src/main/java/net/blueva/menu/common/dto/MenuMetadataDTO.java @@ -10,6 +10,8 @@ public class MenuMetadataDTO { private String type; // "CHEST", "SIMPLE", "MODAL", "CUSTOM" private String openCommand; // Command to open the menu private int itemCount; // Number of items/buttons (optional) + private boolean registered = true; // Whether the menu is registered in java_menus/bedrock_menus and actually loaded + private String source = "disk"; // Where the menu content lives: "disk" or "mysql" // Default constructor for JSON serialization public MenuMetadataDTO() { @@ -24,6 +26,13 @@ public MenuMetadataDTO(String fileName, String menuName, String platform, String this.itemCount = itemCount; } + public MenuMetadataDTO(String fileName, String menuName, String platform, String type, String openCommand, + int itemCount, boolean registered, String source) { + this(fileName, menuName, platform, type, openCommand, itemCount); + this.registered = registered; + this.source = source; + } + // Getters and setters public String getFileName() { return fileName; @@ -72,4 +81,20 @@ public int getItemCount() { public void setItemCount(int itemCount) { this.itemCount = itemCount; } + + public boolean isRegistered() { + return registered; + } + + public void setRegistered(boolean registered) { + this.registered = registered; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } } diff --git a/src/main/java/net/blueva/menu/configuration/ConfigManager.java b/src/main/java/net/blueva/menu/configuration/ConfigManager.java index 4e6884e..ad1d3d7 100644 --- a/src/main/java/net/blueva/menu/configuration/ConfigManager.java +++ b/src/main/java/net/blueva/menu/configuration/ConfigManager.java @@ -68,13 +68,18 @@ public void generateFolders() { public void generateFile(String file, String folder) throws IOException { File cfgFile = new File(main.getDataFolder() + folder + "/", file + ".yml"); + // Example menus are meant to be copied and edited freely - never auto-update them + // against the bundled resource, or a version bump would reorder/strip the user's keys. + if (cfgFile.exists()) { + return; + } YamlDocument.create( cfgFile, Objects.requireNonNull(main.getResource("net/blueva/menu/configuration/files"+folder+"/" + file + ".yml")), GeneralSettings.DEFAULT, - LoaderSettings.builder().setAutoUpdate(true).build(), + LoaderSettings.DEFAULT, DumperSettings.DEFAULT, - UpdaterSettings.builder().setVersioning(new BasicVersioning("file_version")).build() + UpdaterSettings.DEFAULT ); } diff --git a/src/main/java/net/blueva/menu/listeners/InventoryInteractListener.java b/src/main/java/net/blueva/menu/listeners/InventoryInteractListener.java index 45698b1..5fd208c 100644 --- a/src/main/java/net/blueva/menu/listeners/InventoryInteractListener.java +++ b/src/main/java/net/blueva/menu/listeners/InventoryInteractListener.java @@ -1,17 +1,34 @@ package net.blueva.menu.listeners; +import fr.mrmicky.fastinv.FastInv; +import net.blueva.menu.Main; import net.blueva.menu.managers.java.PlayerManager; +import org.bukkit.entity.HumanEntity; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryInteractEvent; +/** + * Safety net: cancel any interaction (click, drag, shift-move) while a BlueMenu Java menu + * is open, so items in the GUI can never be taken or duplicated even if FastInv misses a case. + */ public class InventoryInteractListener implements Listener { @EventHandler - public void IIL(InventoryInteractEvent e) { - Player player = (Player) e.getView().getPlayer(); + public void onInteract(InventoryInteractEvent e) { + HumanEntity viewer = e.getView().getPlayer(); + if (!(viewer instanceof Player player)) { + return; + } + if (PlayerManager.isPlayerInMenu(player)) { e.setCancelled(true); + return; + } + + FastInv menu = Main.getPlugin().javaMenuManager.getActiveMenu(player); + if (menu != null && e.getInventory().equals(menu.getInventory())) { + e.setCancelled(true); } } } diff --git a/src/main/java/net/blueva/menu/listeners/PlayerCommandPreprocessListener.java b/src/main/java/net/blueva/menu/listeners/PlayerCommandPreprocessListener.java index 1c3fa13..f096270 100644 --- a/src/main/java/net/blueva/menu/listeners/PlayerCommandPreprocessListener.java +++ b/src/main/java/net/blueva/menu/listeners/PlayerCommandPreprocessListener.java @@ -2,16 +2,12 @@ import dev.dejvokep.boostedyaml.YamlDocument; import net.blueva.menu.Main; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; -import org.bukkit.event.inventory.InventoryType; import org.bukkit.event.player.PlayerCommandPreprocessEvent; import org.geysermc.floodgate.api.FloodgateApi; -import org.geysermc.floodgate.api.player.FloodgatePlayer; -import java.io.File; import java.util.Map; public class PlayerCommandPreprocessListener implements Listener { @@ -38,32 +34,49 @@ public void PCPL(PlayerCommandPreprocessEvent e) { } private static void checkJavaMenu(Main main, PlayerCommandPreprocessEvent e) { - String message = e.getMessage().trim(); + String label = firstToken(e.getMessage()); for (Map.Entry entry : main.javaMenuManager.menuConfigs.entrySet()) { - String openCommand = entry.getValue().getString("openCommand"); - if (openCommand == null || openCommand.trim().isEmpty()) { + if (!matchesOpenCommand(entry.getValue(), label)) { continue; } - if (message.equalsIgnoreCase(openCommand.trim())) { - e.setCancelled(true); - main.javaMenuManager.openMenu(e.getPlayer(), entry.getKey()); - return; // first match wins, avoid opening several menus on a duplicate command + String permission = entry.getValue().getString("openPermission", ""); + if (!permission.isBlank() && !e.getPlayer().hasPermission(permission)) { + return; // command matched a menu, but the player may not open it } + e.setCancelled(true); + main.javaMenuManager.openMenu(e.getPlayer(), entry.getKey()); + return; // first match wins, avoid opening several menus on a duplicate command } } private static void checkBedrockMenu(Main main, PlayerCommandPreprocessEvent e) { - String message = e.getMessage().trim(); + String label = firstToken(e.getMessage()); for (Map.Entry entry : main.bedrockMenuManager.menuConfigs.entrySet()) { - String openCommand = entry.getValue().getString("openCommand"); - if (openCommand == null || openCommand.trim().isEmpty()) { + if (!matchesOpenCommand(entry.getValue(), label)) { continue; } - if (message.equalsIgnoreCase(openCommand.trim())) { - e.setCancelled(true); - main.bedrockMenuManager.openMenu(e.getPlayer(), entry.getKey()); - return; // first match wins + String permission = entry.getValue().getString("openPermission", ""); + if (!permission.isBlank() && !e.getPlayer().hasPermission(permission)) { + return; } + e.setCancelled(true); + main.bedrockMenuManager.openMenu(e.getPlayer(), entry.getKey()); + return; // first match wins } } + + /** The command word without any trailing arguments, e.g. "/shop buy 3" -> "/shop". */ + private static String firstToken(String message) { + String trimmed = message.trim(); + int space = trimmed.indexOf(' '); + return space > 0 ? trimmed.substring(0, space) : trimmed; + } + + private static boolean matchesOpenCommand(YamlDocument menuConfig, String label) { + String openCommand = menuConfig.getString("openCommand"); + if (openCommand == null || openCommand.trim().isEmpty()) { + return false; + } + return label.equalsIgnoreCase(openCommand.trim()); + } } diff --git a/src/main/java/net/blueva/menu/listeners/PlayerJoinListener.java b/src/main/java/net/blueva/menu/listeners/PlayerJoinListener.java index 3e3a6ca..eb625ad 100644 --- a/src/main/java/net/blueva/menu/listeners/PlayerJoinListener.java +++ b/src/main/java/net/blueva/menu/listeners/PlayerJoinListener.java @@ -1,18 +1,20 @@ package net.blueva.menu.listeners; import net.blueva.menu.managers.java.PlayerManager; -import org.bukkit.Bukkit; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; -import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +/** + * Cleans up per-player menu state when a player leaves so nothing is kept in memory. + *

+ * No join handler is needed any more: {@link PlayerManager} creates entries lazily and + * unconditionally the first time a menu is opened, which also fixes players that were + * already online when the plugin (re)loaded. + */ public class PlayerJoinListener implements Listener { @EventHandler - public void PJL(PlayerJoinEvent e) { - if(!PlayerManager.playerInMenu.containsKey(e.getPlayer())) { - PlayerManager.playerInMenu.put(e.getPlayer(), false); - PlayerManager.playerMenuTitle.put(e.getPlayer(), "None"); - PlayerManager.playerMenuName.put(e.getPlayer(), null); - } + public void onQuit(PlayerQuitEvent e) { + PlayerManager.forget(e.getPlayer()); } } diff --git a/src/main/java/net/blueva/menu/managers/bedrock/CustomManager.java b/src/main/java/net/blueva/menu/managers/bedrock/CustomManager.java index 76d1a5e..58a5b97 100644 --- a/src/main/java/net/blueva/menu/managers/bedrock/CustomManager.java +++ b/src/main/java/net/blueva/menu/managers/bedrock/CustomManager.java @@ -15,9 +15,12 @@ public class CustomManager { public static void openMenu(Player player, YamlDocument menuConfig) { FloodgatePlayer playerB = FloodgateApi.getInstance().getPlayer(player.getUniqueId()); + if (playerB == null) { + return; + } CustomForm.Builder formBuilder = CustomForm.builder() - .title(MessagesUtil.format(player, Objects.requireNonNull(menuConfig.getString("menuName")))); + .title(MessagesUtil.format(player, menuConfig.getString("menuName", ""))); // Storage to preserve component order and their actions List componentsOrder = new ArrayList<>(); @@ -37,6 +40,9 @@ public static void openMenu(Player player, YamlDocument menuConfig) { } } + formBuilder.closedOrInvalidResultHandler(() -> + ActionManager.executeActions(player, menuConfig.getStringList("close_actions"))); + CustomForm form = formBuilder.validResultHandler(response -> { handleResponse(player, response, componentsOrder); }).build(); @@ -70,7 +76,7 @@ private static void addComponent(CustomForm.Builder formBuilder, Section compone return; } - String text = MessagesUtil.format(player, Objects.requireNonNull(component.getString("text", ""))); + String text = MessagesUtil.format(player, component.getString("text", "")); switch (type.toUpperCase()) { case "DROPDOWN": diff --git a/src/main/java/net/blueva/menu/managers/bedrock/MenuManager.java b/src/main/java/net/blueva/menu/managers/bedrock/MenuManager.java index b7acfcf..71ad970 100644 --- a/src/main/java/net/blueva/menu/managers/bedrock/MenuManager.java +++ b/src/main/java/net/blueva/menu/managers/bedrock/MenuManager.java @@ -2,7 +2,9 @@ import dev.dejvokep.boostedyaml.YamlDocument; import net.blueva.menu.Main; +import net.blueva.menu.managers.ConditionManager; import org.bukkit.entity.Player; +import org.geysermc.floodgate.api.FloodgateApi; import java.io.File; import java.util.ArrayList; @@ -41,21 +43,72 @@ public void updateMenuConfig(String menuName, YamlDocument menuConfig) { public void openMenu(Player player, String menuName) { YamlDocument menuConfig = menuConfigs.get(menuName); - if(menuConfig != null) { - lastOpenedMenus.put(player.getUniqueId(), menuName); - String menuType = menuConfig.getString("type"); - if(menuType != null) { - if(menuType.equalsIgnoreCase("SIMPLE")) { - SimpleManager.openMenu(player, menuConfig); - } - if(menuType.equalsIgnoreCase("MODAL")) { - ModalManager.openMenu(player, menuConfig); - } - if(menuType.equalsIgnoreCase("CUSTOM")) { - CustomManager.openMenu(player, menuConfig); + if (menuConfig == null) { + return; + } + + // A Bedrock form can only be sent to a Floodgate (Bedrock) player. + if (FloodgateApi.getInstance().getPlayer(player.getUniqueId()) == null) { + main.getLogger().warning("Cannot open Bedrock menu '" + menuName + "' for " + player.getName() + + ": they are not a Floodgate (Bedrock) player."); + return; + } + + if (!canOpenMenu(player, menuConfig)) { + return; + } + + List openActions = menuConfig.getStringList("open_actions"); + if (!openActions.isEmpty()) { + ActionManager.executeActions(player, openActions); + } + + lastOpenedMenus.put(player.getUniqueId(), menuName); + String menuType = menuConfig.getString("type", ""); + + try { + if (menuType.equalsIgnoreCase("SIMPLE")) { + SimpleManager.openMenu(player, menuConfig); + } else if (menuType.equalsIgnoreCase("MODAL")) { + ModalManager.openMenu(player, menuConfig); + } else if (menuType.equalsIgnoreCase("CUSTOM")) { + CustomManager.openMenu(player, menuConfig); + } else { + main.getLogger().warning("Bedrock menu '" + menuName + "' has an unknown type: '" + menuType + "'"); + } + } catch (Exception e) { + main.getLogger().warning("Failed to open Bedrock menu '" + menuName + "' for " + player.getName() + + ": " + e.getMessage()); + } + } + + private boolean canOpenMenu(Player player, YamlDocument menuConfig) { + if (!menuConfig.contains("open_conditions")) { + return true; + } + + Object openConditionsObj = menuConfig.get("open_conditions"); + if (openConditionsObj instanceof Map rawMap) { + @SuppressWarnings("unchecked") + Map openConditionsMap = (Map) rawMap; + return ConditionManager.evaluateConditionsMap(player, openConditionsMap); + } + + if (openConditionsObj instanceof List rawList) { + List conditions = new ArrayList<>(); + for (Object condition : rawList) { + if (condition instanceof String conditionString) { + conditions.add(conditionString); } } + return ConditionManager.evaluateConditions(player, conditions); } + + if (openConditionsObj instanceof String singleCondition) { + return ConditionManager.evaluateCondition(player, singleCondition); + } + + return true; } public String getLastOpenedMenu(Player player) { diff --git a/src/main/java/net/blueva/menu/managers/bedrock/ModalManager.java b/src/main/java/net/blueva/menu/managers/bedrock/ModalManager.java index b546396..a897a46 100644 --- a/src/main/java/net/blueva/menu/managers/bedrock/ModalManager.java +++ b/src/main/java/net/blueva/menu/managers/bedrock/ModalManager.java @@ -11,11 +11,13 @@ import java.util.List; import java.util.Map; -import java.util.Objects; public class ModalManager { public static void openMenu(Player player, YamlDocument menuConfig) { FloodgatePlayer playerB = FloodgateApi.getInstance().getPlayer(player.getUniqueId()); + if (playerB == null) { + return; + } List content = MessagesUtil.format(player, menuConfig.getStringList("content")); StringBuilder keyResult = new StringBuilder(); @@ -23,61 +25,25 @@ public static void openMenu(Player player, YamlDocument menuConfig) { keyResult.append(element).append("\n"); } - // Check conditions for buttons (note: ModalForm requires both buttons, so we keep default behavior) - // Conditions only prevent actions from executing, not button display - - // Get button configurations - Section button1Config = menuConfig.getSection("buttons.button1"); - Section button2Config = menuConfig.getSection("buttons.button2"); - - // Check button1 conditions - boolean button1Visible = true; - if (button1Config != null && button1Config.contains("display_conditions")) { - Object conditionsObj = button1Config.get("display_conditions"); - if (conditionsObj instanceof Map) { - @SuppressWarnings("unchecked") - Map conditionsMap = (Map) conditionsObj; - button1Visible = ConditionManager.evaluateConditionsMap(player, conditionsMap); - } else if (conditionsObj instanceof List) { - @SuppressWarnings("unchecked") - List conditionsList = (List) conditionsObj; - button1Visible = ConditionManager.evaluateConditions(player, conditionsList); - } - } - - // Check button2 conditions - boolean button2Visible = true; - if (button2Config != null && button2Config.contains("display_conditions")) { - Object conditionsObj = button2Config.get("display_conditions"); - if (conditionsObj instanceof Map) { - @SuppressWarnings("unchecked") - Map conditionsMap = (Map) conditionsObj; - button2Visible = ConditionManager.evaluateConditionsMap(player, conditionsMap); - } else if (conditionsObj instanceof List) { - @SuppressWarnings("unchecked") - List conditionsList = (List) conditionsObj; - button2Visible = ConditionManager.evaluateConditions(player, conditionsList); - } - } - - final boolean finalButton1Visible = button1Visible; - final boolean finalButton2Visible = button2Visible; + // ModalForm always shows exactly two buttons; conditions gate whether their + // actions run, not whether they appear. + boolean button1Visible = buttonVisible(player, menuConfig.getSection("buttons.button1")); + boolean button2Visible = buttonVisible(player, menuConfig.getSection("buttons.button2")); ModalForm form = ModalForm.builder() - .title(MessagesUtil.format(player, Objects.requireNonNull(menuConfig.getString("menuName")))) + .title(MessagesUtil.format(player, menuConfig.getString("menuName", ""))) .content(keyResult.toString()) - .button1(MessagesUtil.format(player, Objects.requireNonNull(menuConfig.getString("buttons.button1.text")))) - .button2(MessagesUtil.format(player, Objects.requireNonNull(menuConfig.getString("buttons.button2.text")))) + .button1(MessagesUtil.format(player, menuConfig.getString("buttons.button1.text", ""))) + .button2(MessagesUtil.format(player, menuConfig.getString("buttons.button2.text", ""))) + .closedOrInvalidResultHandler(() -> + ActionManager.executeActions(player, menuConfig.getStringList("close_actions"))) .validResultHandler(result -> { - if(result.clickedButtonText().equals(MessagesUtil.format(player, Objects.requireNonNull(menuConfig.getString("buttons.button1.text"))))) { - // Only execute actions if button1 conditions pass - if (finalButton1Visible) { + if (result.clickedFirst()) { + if (button1Visible) { ActionManager.executeActions(player, menuConfig.getStringList("buttons.button1.actions")); } - } - if(result.clickedButtonText().equals(MessagesUtil.format(player, Objects.requireNonNull(menuConfig.getString("buttons.button2.text"))))) { - // Only execute actions if button2 conditions pass - if (finalButton2Visible) { + } else { + if (button2Visible) { ActionManager.executeActions(player, menuConfig.getStringList("buttons.button2.actions")); } } @@ -86,4 +52,22 @@ public static void openMenu(Player player, YamlDocument menuConfig) { playerB.sendForm(form); } + + private static boolean buttonVisible(Player player, Section buttonConfig) { + if (buttonConfig == null || !buttonConfig.contains("display_conditions")) { + return true; + } + Object conditionsObj = buttonConfig.get("display_conditions"); + if (conditionsObj instanceof Map) { + @SuppressWarnings("unchecked") + Map conditionsMap = (Map) conditionsObj; + return ConditionManager.evaluateConditionsMap(player, conditionsMap); + } + if (conditionsObj instanceof List) { + @SuppressWarnings("unchecked") + List conditionsList = (List) conditionsObj; + return ConditionManager.evaluateConditions(player, conditionsList); + } + return true; + } } diff --git a/src/main/java/net/blueva/menu/managers/bedrock/SimpleManager.java b/src/main/java/net/blueva/menu/managers/bedrock/SimpleManager.java index 7477e92..3e218ee 100644 --- a/src/main/java/net/blueva/menu/managers/bedrock/SimpleManager.java +++ b/src/main/java/net/blueva/menu/managers/bedrock/SimpleManager.java @@ -13,11 +13,13 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Objects; public class SimpleManager { public static void openMenu(Player player, YamlDocument menuConfig) { FloodgatePlayer playerB = FloodgateApi.getInstance().getPlayer(player.getUniqueId()); + if (playerB == null) { + return; + } List content = MessagesUtil.format(player, menuConfig.getStringList("content")); StringBuilder keyResult = new StringBuilder(); @@ -26,10 +28,10 @@ public static void openMenu(Player player, YamlDocument menuConfig) { } SimpleForm.Builder formBuilder = SimpleForm.builder() - .title(MessagesUtil.format(player, Objects.requireNonNull(menuConfig.getString("menuName")))) + .title(MessagesUtil.format(player, menuConfig.getString("menuName", ""))) .content(keyResult.toString()); - // Store visible buttons for action handling + // Buttons that actually got added, in order - so the clicked index maps back to a key. List visibleButtons = new ArrayList<>(); Section buttonsConfig = menuConfig.getSection("buttons"); @@ -37,55 +39,57 @@ public static void openMenu(Player player, YamlDocument menuConfig) { for (Object buttonKeyObj : buttonsConfig.getKeys()) { String buttonKey = buttonKeyObj.toString(); Section buttonSection = buttonsConfig.getSection(buttonKey); - if (buttonSection != null) { - // Check display conditions before adding the button - boolean shouldDisplay = true; - - if (buttonSection.contains("display_conditions")) { - Object conditionsObj = buttonSection.get("display_conditions"); - if (conditionsObj instanceof Map) { - // Map format with all/any/none - @SuppressWarnings("unchecked") - Map conditionsMap = (Map) conditionsObj; - shouldDisplay = ConditionManager.evaluateConditionsMap(player, conditionsMap); - } else if (conditionsObj instanceof List) { - // Simple list format - @SuppressWarnings("unchecked") - List conditionsList = (List) conditionsObj; - shouldDisplay = ConditionManager.evaluateConditions(player, conditionsList); - } - } + if (buttonSection == null || !shouldDisplay(player, buttonSection)) { + continue; + } - // Only add the button if conditions pass - if (shouldDisplay) { - String buttonText = MessagesUtil.format(player, Objects.requireNonNull(buttonSection.getString("text"))); - if (buttonSection.contains("image")) { - formBuilder.button(buttonText, FormImage.Type.URL, Objects.requireNonNull(buttonSection.getString("image"))); - } else { - formBuilder.button(buttonText); - } - visibleButtons.add(buttonKey); - } + String buttonText = MessagesUtil.format(player, buttonSection.getString("text", "")); + String image = buttonSection.getString("image", ""); + if (!image.isBlank()) { + formBuilder.button(buttonText, FormImage.Type.URL, image); + } else { + formBuilder.button(buttonText); } + visibleButtons.add(buttonKey); } } + formBuilder.closedOrInvalidResultHandler(() -> + ActionManager.executeActions(player, menuConfig.getStringList("close_actions"))); + SimpleForm form = formBuilder.validResultHandler(result -> { - Section buttonsConfigSection = menuConfig.getSection("buttons"); - if (buttonsConfigSection != null) { - // Only check visible buttons for action execution - for (String buttonKey : visibleButtons) { - Section buttonSection = buttonsConfigSection.getSection(buttonKey); - if (buttonSection != null) { - String buttonText = MessagesUtil.format(player, Objects.requireNonNull(buttonSection.getString("text"))); - if (result.clickedButton().text().equals(buttonText)) { - ActionManager.executeActions(player, buttonSection.getStringList("actions")); - } - } - } + int id = result.clickedButtonId(); + if (id < 0 || id >= visibleButtons.size()) { + return; + } + Section buttonsSection = menuConfig.getSection("buttons"); + if (buttonsSection == null) { + return; + } + Section buttonSection = buttonsSection.getSection(visibleButtons.get(id)); + if (buttonSection != null) { + ActionManager.executeActions(player, buttonSection.getStringList("actions")); } }).build(); playerB.sendForm(form); } + + private static boolean shouldDisplay(Player player, Section buttonSection) { + if (!buttonSection.contains("display_conditions")) { + return true; + } + Object conditionsObj = buttonSection.get("display_conditions"); + if (conditionsObj instanceof Map) { + @SuppressWarnings("unchecked") + Map conditionsMap = (Map) conditionsObj; + return ConditionManager.evaluateConditionsMap(player, conditionsMap); + } + if (conditionsObj instanceof List) { + @SuppressWarnings("unchecked") + List conditionsList = (List) conditionsObj; + return ConditionManager.evaluateConditions(player, conditionsList); + } + return true; + } } diff --git a/src/main/java/net/blueva/menu/managers/java/AnimationManager.java b/src/main/java/net/blueva/menu/managers/java/AnimationManager.java index b30e057..417c293 100644 --- a/src/main/java/net/blueva/menu/managers/java/AnimationManager.java +++ b/src/main/java/net/blueva/menu/managers/java/AnimationManager.java @@ -8,79 +8,88 @@ import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.scheduler.BukkitRunnable; +import org.bukkit.scheduler.BukkitTask; import java.util.ArrayList; import java.util.List; public class AnimationManager { + static void startAnimation(Main main, Player player, Section animationConfig, int menuSize) { - int interval = animationConfig.getInt("interval"); - List frames = new ArrayList<>(); + int interval = Math.max(1, animationConfig.getInt("interval", 20)); Section framesSection = animationConfig.getSection("frames"); - if (framesSection != null) { - for (Object frameObj : framesSection.getKeys()) { - String frame = frameObj.toString(); - Section frameSection = framesSection.getSection(frame); - if (frameSection != null) { - ItemStack frameItem = ItemManager.createItemStackFromConfig(frameSection, player); - frames.add(frameItem); - } + if (framesSection == null) { + return; + } + + // Build the frame list in declared key order and carry the slot with each frame, + // instead of assuming the keys are literally "frame1".."frameN". + List frames = new ArrayList<>(); + for (Object frameKeyObj : framesSection.getKeys()) { + Section frameSection = framesSection.getSection(frameKeyObj.toString()); + if (frameSection == null) { + continue; } + + int slot = frameSection.getInt("slot", -1); + if (slot < 0 || slot >= menuSize) { + main.getLogger().warning("Animation frame '" + frameKeyObj + "' has slot " + slot + + " outside the menu (size " + menuSize + ") - skipping."); + continue; + } + + ItemStack item; + try { + item = ItemManager.createItemStackFromConfig(frameSection, player); + item = ItemManager.applyAttributes(item, frameSection.getStringList("attributes")); + } catch (Exception e) { + main.getLogger().warning("Skipping animation frame '" + frameKeyObj + "': " + e.getMessage()); + continue; + } + + frames.add(new AnimationFrame(slot, item)); } - if (!frames.isEmpty()) { - new BukkitRunnable() { - int currentFrame = 0; - - @Override - public void run() { - if (!player.isOnline() || !MenuManager.isMenuOpen(player)) { - // Player logged out, cancel the task - cancel(); - return; - } - - // Get the active FastInv menu - FastInv menu = main.javaMenuManager.getActiveMenu(player); - if (menu == null) { - cancel(); - return; - } - - Inventory inventory = menu.getInventory(); - - // Clear previous frames - for (int i = 0; i < frames.size(); i++) { - Section frameSection = framesSection.getSection("frame" + (i + 1)); - if (frameSection != null) { - int slot = frameSection.getInt("slot"); - if (slot >= 0 && slot < menuSize) { - ItemStack air = new ItemStack(Material.AIR); - inventory.setItem(slot, air); - } - } - } - - // Update inventory with the current frame - if (currentFrame >= frames.size()) { - // Reached the end of frames, start over from the beginning - currentFrame = 0; - } - - Section currentFrameSection = framesSection.getSection("frame" + (currentFrame + 1)); - if (currentFrameSection != null) { - int slot = currentFrameSection.getInt("slot"); - if (slot >= 0 && slot < menuSize) { - ItemStack frameItem = frames.get(currentFrame); - ItemStack frameItemWithAttributes = ItemManager.applyAttributes(frameItem, currentFrameSection.getStringList("attributes")); - inventory.setItem(slot, frameItemWithAttributes); - } - } - - currentFrame++; - } - }.runTaskTimer(main, 0, interval).getTaskId(); + if (frames.isEmpty()) { + return; } + + BukkitTask task = new BukkitRunnable() { + int current = 0; + int previousSlot = -1; + + @Override + public void run() { + FastInv menu = main.javaMenuManager.getActiveMenu(player); + if (!player.isOnline() || !MenuManager.isMenuOpen(player) || menu == null) { + cancel(); + return; + } + + Inventory inventory = menu.getInventory(); + + if (previousSlot >= 0 && previousSlot < inventory.getSize()) { + inventory.setItem(previousSlot, new ItemStack(Material.AIR)); + } + + if (current >= frames.size()) { + current = 0; + } + + AnimationFrame frame = frames.get(current); + if (frame.slot() < inventory.getSize()) { + inventory.setItem(frame.slot(), frame.item()); + previousSlot = frame.slot(); + } + + current++; + } + }.runTaskTimer(main, 0L, interval); + + main.javaMenuManager.registerAnimationTask(player, task); + } + + private record AnimationFrame(int slot, ItemStack item) { } } diff --git a/src/main/java/net/blueva/menu/managers/java/ItemManager.java b/src/main/java/net/blueva/menu/managers/java/ItemManager.java index 264e53d..3991553 100644 --- a/src/main/java/net/blueva/menu/managers/java/ItemManager.java +++ b/src/main/java/net/blueva/menu/managers/java/ItemManager.java @@ -38,7 +38,11 @@ public static ItemStack createItemStackFromConfig(Section itemSection, Player pl if (itemStack != null) { material = itemStack.getType(); } else { - material = Material.valueOf(materialName); + material = parseMaterial(materialName); + if (material == null) { + throw new IllegalArgumentException("unknown or missing 'itemStack.material'" + + (materialName == null ? "" : ": '" + materialName + "'")); + } itemStack = new ItemStack(material); } @@ -71,6 +75,18 @@ public static ItemStack createItemStackFromConfig(Section itemSection, Player pl return itemStack; } + private static Material parseMaterial(String name) { + if (name == null || name.isBlank()) { + return null; + } + String cleaned = name.trim(); + try { + return Material.valueOf(cleaned.toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException ex) { + return Material.matchMaterial(cleaned); + } + } + private static ItemStack createCustomPluginItem(Section itemSection, Player player, String materialName) { String provider = MessagesUtil.format(player, itemSection.getString("itemStack.provider", "")); String id = MessagesUtil.format(player, itemSection.getString("itemStack.id", "")); diff --git a/src/main/java/net/blueva/menu/managers/java/MenuManager.java b/src/main/java/net/blueva/menu/managers/java/MenuManager.java index 969011f..cabc44b 100644 --- a/src/main/java/net/blueva/menu/managers/java/MenuManager.java +++ b/src/main/java/net/blueva/menu/managers/java/MenuManager.java @@ -8,17 +8,21 @@ import net.blueva.menu.utils.MessagesUtil; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import org.bukkit.scheduler.BukkitTask; import java.io.File; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; public class MenuManager { public final Map menuConfigs = new HashMap<>(); public final List menuNames = new ArrayList<>(); public final Map activeMenus = new HashMap<>(); + private final Map> activeAnimations = new ConcurrentHashMap<>(); private final Main main; @@ -26,6 +30,40 @@ public MenuManager(Main main) { this.main = main; } + /** Track an animation task so it can be cancelled when the menu closes or the plugin disables. */ + public void registerAnimationTask(Player player, BukkitTask task) { + activeAnimations.computeIfAbsent(player.getUniqueId(), k -> new ArrayList<>()).add(task); + } + + /** Cancel and forget every animation task running for a player. */ + public void cancelAnimations(Player player) { + List tasks = activeAnimations.remove(player.getUniqueId()); + if (tasks != null) { + for (BukkitTask task : tasks) { + try { + task.cancel(); + } catch (Exception ignored) { + // task already dead + } + } + } + } + + /** Cancel every animation and drop all live menu references. Call on plugin disable. */ + public void shutdown() { + for (List tasks : activeAnimations.values()) { + for (BukkitTask task : tasks) { + try { + task.cancel(); + } catch (Exception ignored) { + // task already dead + } + } + } + activeAnimations.clear(); + activeMenus.clear(); + } + public void loadJavaMenus() { List menuList = main.getConfigManager().getSettings().getStringList("java_menus"); main.getMenuSyncService().loadMenus( @@ -51,12 +89,15 @@ public void openMenu(Player player, String menuName) { return; } + // Kill any animation left over from a previously open menu before we build the new one. + cancelAnimations(player); + List openActions = menuConfig.getStringList("open_actions"); if (!openActions.isEmpty()) { ActionManager.executeActions(player, openActions); } - int menuSize = menuConfig.getInt("menuSize"); + int menuSize = normalizeMenuSize(menuConfig.getInt("menuSize", 27)); String menuTitle = MessagesUtil.format(player, menuConfig.getString("menuName")); // Create FastInv menu @@ -90,8 +131,21 @@ public void openMenu(Player player, String menuName) { // Only add the item if conditions pass if (shouldDisplay) { - ItemStack itemStack = ItemManager.createItemStackFromConfig(itemSection, player); + ItemStack itemStack; + try { + itemStack = ItemManager.createItemStackFromConfig(itemSection, player); + } catch (Exception ex) { + main.getLogger().warning("Skipping menu item '" + itemName + "' in menu '" + + menuName + "': " + ex.getMessage()); + continue; + } + int slot = itemSection.getInt("slot"); + if (slot < 0 || slot >= menuSize) { + main.getLogger().warning("Menu item '" + itemName + "' in menu '" + menuName + + "' has slot " + slot + " outside the menu (size " + menuSize + ") - skipping."); + continue; + } List actions = itemSection.getStringList("actions"); int priority = itemSection.getInt("priority", 0); @@ -116,6 +170,7 @@ public void openMenu(Player player, String menuName) { // Add close handler menu.addCloseHandler(e -> { activeMenus.remove(player); + cancelAnimations(player); PlayerManager.closeMenu(player); }); @@ -182,6 +237,19 @@ static boolean isMenuOpen(Player player) { return PlayerManager.isPlayerInMenu(player); } + /** Clamp a configured chest size to a Bukkit-legal value (9..54, multiple of 9). */ + private int normalizeMenuSize(int configured) { + int size = configured; + if (size < 9) { + size = 9; + } else if (size > 54) { + size = 54; + } else if (size % 9 != 0) { + size = Math.min(54, ((size / 9) + 1) * 9); + } + return size; + } + private record PrioritizedMenuItem(ItemStack itemStack, List actions, int priority) { } } diff --git a/src/main/java/net/blueva/menu/managers/java/PlayerManager.java b/src/main/java/net/blueva/menu/managers/java/PlayerManager.java index 5e6f740..f3e0e4d 100644 --- a/src/main/java/net/blueva/menu/managers/java/PlayerManager.java +++ b/src/main/java/net/blueva/menu/managers/java/PlayerManager.java @@ -3,38 +3,61 @@ import net.blueva.menu.utils.MessagesUtil; import org.bukkit.entity.Player; -import java.util.HashMap; import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +/** + * Tracks which players currently have a BlueMenu Java menu open. + *

+ * Keyed by {@link UUID} (not the {@link Player} object) so entries don't pin a disconnected + * player in memory, and every mutation is unconditional so state is correct even for players + * that were already online when the plugin (re)loaded. + */ public class PlayerManager { - public final static Map playerInMenu = new HashMap<>(); - public final static Map playerMenuTitle = new HashMap<>(); - public final static Map playerMenuName = new HashMap<>(); + private static final Map playerInMenu = new ConcurrentHashMap<>(); + private static final Map playerMenuTitle = new ConcurrentHashMap<>(); + private static final Map playerMenuName = new ConcurrentHashMap<>(); public static boolean isPlayerInMenu(Player player) { - if(!playerInMenu.isEmpty() && playerInMenu.containsKey(player)) { - return playerInMenu.get(player).equals(true); - } - return false; + return player != null && Boolean.TRUE.equals(playerInMenu.get(player.getUniqueId())); } public static void openMenu(Player player, String title, String menuName) { - if(!playerInMenu.isEmpty() && playerInMenu.containsKey(player) && playerInMenu.get(player).equals(false)) { - playerInMenu.replace(player, true); - PlayerManager.playerMenuTitle.replace(player, MessagesUtil.format(player, title)); - PlayerManager.playerMenuName.replace(player, menuName); - } + UUID id = player.getUniqueId(); + playerInMenu.put(id, true); + playerMenuTitle.put(id, MessagesUtil.format(player, title)); + playerMenuName.put(id, menuName); } public static void closeMenu(Player player) { - if(!playerInMenu.isEmpty() && playerInMenu.containsKey(player) && playerInMenu.get(player).equals(true)) { - playerInMenu.replace(player, false); - PlayerManager.playerMenuTitle.replace(player, "None"); - PlayerManager.playerMenuName.remove(player); - } + UUID id = player.getUniqueId(); + playerInMenu.put(id, false); + playerMenuTitle.put(id, "None"); + playerMenuName.remove(id); + } + + /** + * Drop every trace of a player. Call on quit and on plugin disable. + */ + public static void forget(Player player) { + UUID id = player.getUniqueId(); + playerInMenu.remove(id); + playerMenuTitle.remove(id); + playerMenuName.remove(id); + } + + public static void clearAll() { + playerInMenu.clear(); + playerMenuTitle.clear(); + playerMenuName.clear(); } public static String getMenuName(Player player) { - return playerMenuName.get(player); + return playerMenuName.get(player.getUniqueId()); + } + + public static String getMenuTitle(Player player) { + return playerMenuTitle.get(player.getUniqueId()); } } diff --git a/src/main/java/net/blueva/menu/sync/MenuSyncService.java b/src/main/java/net/blueva/menu/sync/MenuSyncService.java index 278f762..d7b7646 100644 --- a/src/main/java/net/blueva/menu/sync/MenuSyncService.java +++ b/src/main/java/net/blueva/menu/sync/MenuSyncService.java @@ -54,6 +54,52 @@ public boolean isEnabled() { return syncConfig != null && syncConfig.enabled(); } + /** + * Whether the given menu is consumed from MySQL on this server (receiver mode). + * Web editor edits for such menus must go to the database, not the local file. + */ + public boolean isReceiverMenu(MenuType type, String menuKey) { + Map states = receiverStates.get(type); + return states != null && states.containsKey(menuKey); + } + + /** + * Read the current YAML of a receiver menu straight from MySQL. + */ + public Optional fetchMenuYaml(MenuType type, String menuKey) { + if (!isRepositoryAvailable()) { + return Optional.empty(); + } + try { + return repository.fetchMenu(type, menuKey).map(MenuRecord::yaml); + } catch (SQLException e) { + main.getLogger().warning("Failed to fetch menu YAML from MySQL for " + menuKey + ": " + e.getMessage()); + return Optional.empty(); + } + } + + /** + * Persist a web-editor edit of a receiver menu into MySQL. The poll task then + * propagates it back into the live menu managers on this and every other server. + */ + public boolean saveReceiverMenuYaml(MenuType type, String menuKey, String fileName, String yaml) { + if (!isRepositoryAvailable()) { + main.getLogger().warning("Cannot save receiver menu " + menuKey + ": MySQL repository unavailable."); + return false; + } + try { + if (repository.fetchMetadata(type, menuKey).isPresent()) { + repository.updateMenu(type, menuKey, fileName, yaml); + } else { + repository.insertMenu(type, menuKey, fileName, yaml); + } + return true; + } catch (SQLException e) { + main.getLogger().warning("Failed to save receiver menu " + menuKey + " to MySQL: " + e.getMessage()); + return false; + } + } + private boolean isRepositoryAvailable() { return syncConfig != null && syncConfig.enabled() && repository != null; } diff --git a/src/main/java/net/blueva/menu/utils/MessagesUtil.java b/src/main/java/net/blueva/menu/utils/MessagesUtil.java index 083d5ad..8743f0e 100644 --- a/src/main/java/net/blueva/menu/utils/MessagesUtil.java +++ b/src/main/java/net/blueva/menu/utils/MessagesUtil.java @@ -49,7 +49,13 @@ public class MessagesUtil { processed = replacePrefixPlaceholder(processed); processed = convertLegacyToMiniMessage(processed); - return miniMessage.deserialize(processed); + try { + return miniMessage.deserialize(processed); + } catch (Exception e) { + // Malformed MiniMessage (typically a stray '<' in user text or a placeholder value). + // Render it literally instead of blowing up the menu / message. + return Component.text(processed); + } } // Send message to player using Adventure diff --git a/src/main/java/net/blueva/menu/webeditor/WebEditorClient.java b/src/main/java/net/blueva/menu/webeditor/WebEditorClient.java index 8c8a66f..315dc00 100644 --- a/src/main/java/net/blueva/menu/webeditor/WebEditorClient.java +++ b/src/main/java/net/blueva/menu/webeditor/WebEditorClient.java @@ -6,6 +6,7 @@ import dev.dejvokep.boostedyaml.block.implementation.Section; import net.blueva.menu.Main; import net.blueva.menu.common.dto.MenuMetadataDTO; +import net.blueva.menu.sync.MenuType; import org.bukkit.Bukkit; import net.blueva.menu.common.protocol.MessageType; import net.blueva.menu.common.protocol.WebSocketMessage; @@ -18,7 +19,10 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.logging.Logger; @@ -39,11 +43,26 @@ public WebEditorClient(URI serverUri, Main plugin, boolean requireSessionConfirm this.plugin = plugin; this.logger = plugin.getLogger(); this.requireSessionConfirmation = requireSessionConfirmation; + // Detect a silently dropped socket faster than the 60s default (proxies love to + // kill idle WebSockets). Combined with the app-level PING watchdog in WebEditorManager. + this.setConnectionLostTimeout(30); } @Override public void onOpen(ServerHandshake handshakedata) { logger.info("Connected to web editor server"); + // Re-announce ourselves so the server (re)binds this socket as THE plugin connection, + // even after an automatic reconnect where no new editor session is created. + try { + WebSocketMessage register = new WebSocketMessage(MessageType.PLUGIN_STATUS); + JsonObject data = new JsonObject(); + data.addProperty("connected", true); + data.addProperty("role", "plugin"); + register.setData(data); + send(gson.toJson(register)); + } catch (Exception e) { + logger.fine("Could not send plugin register message: " + e.getMessage()); + } } @Override @@ -64,8 +83,9 @@ public void onMessage(String message) { case MENU_DELETE -> handleMenuDelete(msg); case PONG -> {} // Silent case ERROR -> handleError(msg); - // Messages we send (ignore when broadcast back to us) - case MENU_LIST, MENU_DATA, MENU_SAVED, MENU_DELETED -> {} // Silent (we sent these) + case MENU_UPDATE -> {} // Live-edit channel not implemented plugin-side yet + // Messages we send (ignore when echoed back to us) + case MENU_LIST, MENU_DATA, MENU_SAVED, MENU_DELETED, PLUGIN_STATUS -> {} // Silent (we sent these) default -> logger.warning("Unhandled message type: " + msg.getType()); } } catch (Exception e) { @@ -125,11 +145,18 @@ public CompletableFuture confirmSession(String verificationId, java.uti } /** - * Send a ping to the server + * Send an application-level ping to the server. Called periodically by the + * WebEditorManager watchdog to keep proxies from idling the connection out. */ public void sendPing() { - WebSocketMessage ping = new WebSocketMessage(MessageType.PING); - send(gson.toJson(ping)); + try { + if (isOpen()) { + WebSocketMessage ping = new WebSocketMessage(MessageType.PING); + send(gson.toJson(ping)); + } + } catch (Exception e) { + logger.fine("Failed to send ping: " + e.getMessage()); + } } private void handleSessionValid(WebSocketMessage msg) { @@ -235,6 +262,27 @@ private void handleMenuSave(WebSocketMessage msg) { return; } + // Receiver menus are owned by MySQL - the edit must go to the database, not a local + // file the plugin never reads. The sync poll task then propagates it everywhere. + if (!platform.equalsIgnoreCase("CONFIG")) { + String folderName = platform.equalsIgnoreCase("JAVA") ? "java" : "bedrock"; + MenuType type = platform.equalsIgnoreCase("JAVA") ? MenuType.JAVA : MenuType.BEDROCK; + String menuKey = resolveMenuKey(fileName, folderName); + if (menuKey != null && plugin.getMenuSyncService() != null + && plugin.getMenuSyncService().isReceiverMenu(type, menuKey)) { + boolean ok = plugin.getMenuSyncService().saveReceiverMenuYaml(type, menuKey, fileName, content); + if (ok) { + sendMenuSaved(fileName, platform, sessionId, + "This menu is synced from MySQL. The change was written to the database " + + "and will propagate to every server on the next sync poll."); + logger.info("Receiver menu saved to MySQL: " + fileName + " (" + menuKey + ")"); + } else { + sendError("Failed to save menu to MySQL", sessionId); + } + return; + } + } + // Save menu to disk String targetFileName = platform.equalsIgnoreCase("CONFIG") ? SETTINGS_FILE_NAME : fileName; @@ -263,7 +311,13 @@ private void handleMenuSave(WebSocketMessage msg) { } // Send success confirmation - sendMenuSaved(targetFileName, platform, sessionId); + if (platform.equalsIgnoreCase("CONFIG")) { + sendMenuSaved(targetFileName, platform, sessionId, + "Settings reloaded. Note: changes to webeditor.* and metrics only take effect " + + "after a full server restart."); + } else { + sendMenuSaved(targetFileName, platform, sessionId, null); + } logger.info("Menu saved: " + targetFileName); } else { sendError("Failed to save menu to disk", sessionId); @@ -308,72 +362,96 @@ private void handleMenuDelete(WebSocketMessage msg) { } /** - * Get list of all menus (Java + Bedrock) by scanning filesystem + * Build the menu list the web editor sees. + *

+ * Source of truth is what the plugin actually has loaded ({@code menuNames}/{@code menuConfigs}): + * that covers registered files AND MySQL receiver menus. Any extra {@code .yml} sitting in the + * folder but not registered is still listed, flagged {@code registered=false} so the editor can + * warn the user instead of silently pretending it is a live menu. */ private List getMenuList() { List menus = new ArrayList<>(); - // Scan Java menus from filesystem - File javaMenusDir = new File(plugin.getDataFolder(), "menus/java"); - if (javaMenusDir.exists() && javaMenusDir.isDirectory()) { - File[] javaFiles = javaMenusDir.listFiles((dir, name) -> name.toLowerCase().endsWith(".yml")); - if (javaFiles != null) { - for (File file : javaFiles) { - try { - YamlDocument config = YamlDocument.create(file); - String fileName = file.getName(); - String displayName = config.getString("menuName", fileName.replace(".yml", "")); - String type = config.getString("type", "CHEST"); - String openCommand = config.getString("openCommand", ""); - - // Count items - int itemCount = 0; - Section itemsSection = config.getSection("items"); - if (itemsSection != null) { - itemCount = itemsSection.getKeys().size(); - } - - menus.add(new MenuMetadataDTO(fileName, displayName, "JAVA", type, openCommand, itemCount)); - } catch (Exception e) { - logger.warning("Error reading Java menu file: " + file.getName() + " - " + e.getMessage()); - } + List javaKeys = plugin.javaMenuManager != null + ? plugin.javaMenuManager.menuNames : new ArrayList(); + Map javaConfigs = plugin.javaMenuManager != null + ? plugin.javaMenuManager.menuConfigs : new LinkedHashMap(); + collectMenusForPlatform(menus, "JAVA", "java", MenuType.JAVA, "CHEST", javaKeys, javaConfigs, "menus/java"); + + List bedrockKeys = plugin.bedrockMenuManager != null + ? plugin.bedrockMenuManager.menuNames : new ArrayList(); + Map bedrockConfigs = plugin.bedrockMenuManager != null + ? plugin.bedrockMenuManager.menuConfigs : new LinkedHashMap(); + collectMenusForPlatform(menus, "BEDROCK", "bedrock", MenuType.BEDROCK, "FORM", bedrockKeys, bedrockConfigs, + "menus/bedrock"); + + logger.fine("Reporting " + menus.size() + " menus to the web editor"); + return menus; + } + + private void collectMenusForPlatform(List out, String platformLabel, String platformKey, + MenuType type, String defaultType, List loadedKeys, + Map loadedConfigs, String folder) { + java.util.Set seenFiles = new java.util.HashSet<>(); + + // 1) Menus the plugin actually has loaded + for (String menuKey : new ArrayList<>(loadedKeys)) { + YamlDocument config = loadedConfigs.get(menuKey); + if (config == null) { + continue; + } + String fileName = getFileNameForMenu(menuKey, platformKey); + seenFiles.add(fileName.toLowerCase()); + boolean receiver = plugin.getMenuSyncService() != null + && plugin.getMenuSyncService().isReceiverMenu(type, menuKey); + out.add(buildMenuDto(config, fileName, platformLabel, defaultType, true, receiver ? "mysql" : "disk")); + } + + // 2) Orphan .yml files on disk that are not registered / not loaded + File dir = new File(plugin.getDataFolder(), folder); + File[] files = dir.listFiles((d, name) -> name.toLowerCase().endsWith(".yml")); + if (files != null) { + for (File file : files) { + if (seenFiles.contains(file.getName().toLowerCase())) { + continue; + } + try { + YamlDocument config = YamlDocument.create(file); + out.add(buildMenuDto(config, file.getName(), platformLabel, defaultType, false, "disk")); + } catch (Exception e) { + logger.warning("Error reading menu file: " + file.getName() + " - " + e.getMessage()); } } } + } - // Scan Bedrock menus from filesystem - File bedrockMenusDir = new File(plugin.getDataFolder(), "menus/bedrock"); - if (bedrockMenusDir.exists() && bedrockMenusDir.isDirectory()) { - File[] bedrockFiles = bedrockMenusDir.listFiles((dir, name) -> name.toLowerCase().endsWith(".yml")); - if (bedrockFiles != null) { - for (File file : bedrockFiles) { - try { - YamlDocument config = YamlDocument.create(file); - String fileName = file.getName(); - String displayName = config.getString("menuName", fileName.replace(".yml", "")); - String type = config.getString("type", "FORM"); - String openCommand = config.getString("openCommand", ""); - - // Count buttons/components - int itemCount = 0; - Section buttonsSection = config.getSection("buttons"); - Section componentsSection = config.getSection("components"); - if (buttonsSection != null) { - itemCount = buttonsSection.getKeys().size(); - } else if (componentsSection != null) { - itemCount = componentsSection.getKeys().size(); - } - - menus.add(new MenuMetadataDTO(fileName, displayName, "BEDROCK", type, openCommand, itemCount)); - } catch (Exception e) { - logger.warning("Error reading Bedrock menu file: " + file.getName() + " - " + e.getMessage()); - } - } - } + private MenuMetadataDTO buildMenuDto(YamlDocument config, String fileName, String platformLabel, + String defaultType, boolean registered, String source) { + String displayName = config.getString("menuName", fileName.replace(".yml", "")); + String menuType = config.getString("type", defaultType); + String openCommand = config.getString("openCommand", ""); + + int itemCount = 0; + Section items = config.getSection("items"); + Section buttons = config.getSection("buttons"); + Section components = config.getSection("components"); + if (items != null) { + itemCount = items.getKeys().size(); + } else if (buttons != null) { + itemCount = buttons.getKeys().size(); + } else if (components != null) { + itemCount = components.getKeys().size(); } - logger.fine("Found " + menus.size() + " menus from filesystem"); - return menus; + return new MenuMetadataDTO(fileName, displayName, platformLabel, menuType, openCommand, itemCount, + registered, source); + } + + private YamlDocument loadedConfigFor(MenuType type, String menuKey) { + if (type == MenuType.JAVA) { + return plugin.javaMenuManager != null ? plugin.javaMenuManager.menuConfigs.get(menuKey) : null; + } + return plugin.bedrockMenuManager != null ? plugin.bedrockMenuManager.menuConfigs.get(menuKey) : null; } /** @@ -416,16 +494,34 @@ public void sendMenuList(List menus, String sessionId) { */ private String getMenuContent(String fileName, String platform) { try { - File menuFile; - // Special handling for settings.yml if (platform.equalsIgnoreCase("CONFIG")) { - menuFile = new File(plugin.getDataFolder(), SETTINGS_FILE_NAME); - } else { - String folderName = platform.equalsIgnoreCase("JAVA") ? "java" : "bedrock"; - menuFile = new File(plugin.getDataFolder() + "/menus/" + folderName, fileName); + File settings = new File(plugin.getDataFolder(), SETTINGS_FILE_NAME); + if (!settings.exists()) { + logger.warning("Settings file not found: " + settings.getPath()); + return null; + } + return Files.readString(settings.toPath()); + } + + String folderName = platform.equalsIgnoreCase("JAVA") ? "java" : "bedrock"; + MenuType type = platform.equalsIgnoreCase("JAVA") ? MenuType.JAVA : MenuType.BEDROCK; + String menuKey = resolveMenuKey(fileName, folderName); + + // Receiver menus live in MySQL, not on disk - serve the database copy + if (menuKey != null && plugin.getMenuSyncService() != null + && plugin.getMenuSyncService().isReceiverMenu(type, menuKey)) { + Optional dbYaml = plugin.getMenuSyncService().fetchMenuYaml(type, menuKey); + if (dbYaml.isPresent()) { + return dbYaml.get(); + } + YamlDocument loaded = loadedConfigFor(type, menuKey); + if (loaded != null) { + return loaded.dump(); + } } + File menuFile = new File(plugin.getDataFolder() + "/menus/" + folderName, fileName); if (!menuFile.exists()) { logger.warning("Menu file not found: " + menuFile.getPath()); return null; @@ -641,14 +737,8 @@ private void reloadPlugin() { // Run on main thread plugin.getServer().getScheduler().runTask(plugin, () -> { try { - // Mirror what /bm reload does so settings.yml changes actually take effect - plugin.getConfigManager().reloadSettings(); - plugin.getConfigManager().reloadLang(); - plugin.getMenuSyncService().reload(); - - // Reload all menus - plugin.javaMenuManager.loadJavaMenus(); - plugin.bedrockMenuManager.loadBedrockMenus(); + // Exact same path as /bm reload so the two can never diverge + plugin.reloadAll(); logger.info("Plugin configuration and menus reloaded successfully"); logger.info("Note: webeditor.* and metrics changes still require a full server restart"); @@ -680,49 +770,81 @@ private String getMenuNameFromFileName(String fileName, String platform) { return null; } + /** + * Resolve the menuKey the plugin uses for a given file name. + * Checks settings.yml first, then the live managers so MySQL receive-wildcard + * menus (which are never listed in settings.yml) still resolve. + */ + private String resolveMenuKey(String fileName, String folderName) { + String fromSettings = getMenuNameFromFileName(fileName, folderName); + if (fromSettings != null) { + return fromSettings; + } + + Map configs = folderName.equals("java") + ? (plugin.javaMenuManager != null ? plugin.javaMenuManager.menuConfigs : null) + : (plugin.bedrockMenuManager != null ? plugin.bedrockMenuManager.menuConfigs : null); + if (configs == null) { + return null; + } + + String base = fileName.toLowerCase().endsWith(".yml") + ? fileName.substring(0, fileName.length() - 4) : fileName; + for (String key : configs.keySet()) { + if (key.equalsIgnoreCase(base) || getFileNameForMenu(key, folderName).equalsIgnoreCase(fileName)) { + return key; + } + } + return null; + } + /** * Refresh (close and reopen) menus for players who have them open * Only works for Java menus */ private void refreshOpenMenus(String menuName) { - // Get all players who have this menu open + // Only players who actually have THIS menu open - not everyone with any menu open. List playersToRefresh = new ArrayList<>(); for (var entry : plugin.javaMenuManager.activeMenus.entrySet()) { org.bukkit.entity.Player player = entry.getKey(); - fr.mrmicky.fastinv.FastInv menu = entry.getValue(); - - // Check if this player has the specific menu open - if (menu != null) { + if (entry.getValue() == null) { + continue; + } + if (menuName.equals(net.blueva.menu.managers.java.PlayerManager.getMenuName(player))) { playersToRefresh.add(player); } } - // Close and reopen menus after a short delay - if (!playersToRefresh.isEmpty()) { - plugin.getServer().getScheduler().runTaskLater(plugin, () -> { - for (org.bukkit.entity.Player player : playersToRefresh) { - if (player.isOnline() && plugin.javaMenuManager.activeMenus.containsKey(player)) { - // Close current menu - player.closeInventory(); - - // Reopen menu after a tick - plugin.getServer().getScheduler().runTaskLater(plugin, () -> { - if (player.isOnline()) { - plugin.javaMenuManager.openMenu(player, menuName); - logger.fine("Refreshed menu for player: " + player.getName()); - } - }, 2L); - } - } - }, 5L); + if (playersToRefresh.isEmpty()) { + return; } + + plugin.getServer().getScheduler().runTaskLater(plugin, () -> { + for (org.bukkit.entity.Player player : playersToRefresh) { + if (!player.isOnline() + || !menuName.equals(net.blueva.menu.managers.java.PlayerManager.getMenuName(player))) { + continue; + } + player.closeInventory(); + plugin.getServer().getScheduler().runTaskLater(plugin, () -> { + if (player.isOnline()) { + plugin.javaMenuManager.openMenu(player, menuName); + logger.fine("Refreshed menu for player: " + player.getName()); + } + }, 2L); + } + }, 5L); } /** * Send menu saved confirmation to the server */ private void sendMenuSaved(String fileName, String platform, String sessionId) { + sendMenuSaved(fileName, platform, sessionId, null); + } + + private void sendMenuSaved(String fileName, String platform, String sessionId, String warning) { WebSocketMessage msg = new WebSocketMessage(MessageType.MENU_SAVED); JsonObject data = new JsonObject(); @@ -730,6 +852,9 @@ private void sendMenuSaved(String fileName, String platform, String sessionId) { data.addProperty("platform", platform); data.addProperty("success", true); data.addProperty("message", "Menu saved successfully"); + if (warning != null && !warning.isEmpty()) { + data.addProperty("warning", warning); + } if (sessionId != null) { data.addProperty("sessionId", sessionId); } diff --git a/src/main/java/net/blueva/menu/webeditor/WebEditorManager.java b/src/main/java/net/blueva/menu/webeditor/WebEditorManager.java index 7bd5563..d1ecafe 100644 --- a/src/main/java/net/blueva/menu/webeditor/WebEditorManager.java +++ b/src/main/java/net/blueva/menu/webeditor/WebEditorManager.java @@ -1,7 +1,9 @@ package net.blueva.menu.webeditor; +import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; import java.net.URI; import java.util.concurrent.CompletableFuture; @@ -18,6 +20,10 @@ public class WebEditorManager { private final boolean requireSessionConfirmation; private final WebEditorEnvironment environment; private WebEditorClient client; + private BukkitTask watchdogTask; + + /** How often the watchdog pings / checks the connection, in ticks (15s). */ + private static final long WATCHDOG_INTERVAL_TICKS = 20L * 15; public WebEditorManager(Plugin plugin, boolean enabled, boolean requireSessionConfirmation, WebEditorEnvironment environment) { @@ -47,15 +53,68 @@ public void connect() { logger.warning("Development environment is intended for plugin contributors only." + " Do not use on production servers."); } + startWatchdog(); } catch (Exception e) { logger.severe("Failed to connect to web editor server: " + e.getMessage()); } } + /** + * Periodic keepalive + auto-reconnect. Proxies in front of the official server + * silently drop idle WebSockets; without this the editor goes dark until a restart. + */ + private void startWatchdog() { + stopWatchdog(); + watchdogTask = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, () -> { + if (!enabled) { + return; + } + try { + if (client == null || client.isClosed()) { + reconnect(); + } else if (client.isOpen()) { + client.sendPing(); + } + } catch (Exception e) { + logger.fine("Web editor watchdog error: " + e.getMessage()); + } + }, WATCHDOG_INTERVAL_TICKS, WATCHDOG_INTERVAL_TICKS); + } + + private void stopWatchdog() { + if (watchdogTask != null) { + watchdogTask.cancel(); + watchdogTask = null; + } + } + + private void reconnect() { + if (!enabled) { + return; + } + WebEditorClient old = client; + if (old != null) { + try { + old.closeBlocking(); + } catch (Exception ignored) { + // best effort + } + } + try { + URI serverUri = new URI(environment.websocketUrl()); + client = new WebEditorClient(serverUri, (net.blueva.menu.Main) plugin, requireSessionConfirmation); + client.connect(); + logger.info("Reconnecting to BlueMenu web editor at " + environment.websocketUrl()); + } catch (Exception e) { + logger.warning("Web editor reconnect failed: " + e.getMessage()); + } + } + /** * Disconnect from the web editor server */ public void disconnect() { + stopWatchdog(); if (client != null && client.isOpen()) { client.close(); logger.info("Disconnected from web editor server");