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");