diff --git a/build.gradle b/build.gradle index 0e35325d..a1cfc09d 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'java-library' id 'maven-publish' + id 'com.gradleup.shadow' version '8.3.5' apply false } allprojects { diff --git a/core/build.gradle b/core/build.gradle index 5109fc1b..c4fda557 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,3 +1,13 @@ +plugins { + id 'com.gradleup.shadow' +} + +// Create a custom configuration for database dependencies to shade +configurations { + shade + implementation.extendsFrom(shade) +} + dependencies { // API api project(':api') @@ -5,6 +15,11 @@ dependencies { // Paper API compileOnly 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT' + // Database dependencies (shaded) + shade 'com.zaxxer:HikariCP:5.1.0' + shade 'org.mariadb.jdbc:mariadb-java-client:3.3.2' + shade 'org.xerial:sqlite-jdbc:3.45.1.0' + // Hook plugins compileOnly 'org.geysermc.floodgate:api:2.2.5-SNAPSHOT' compileOnly 'com.sk89q.worldguard:worldguard-bukkit:7.1.0-SNAPSHOT' @@ -57,6 +72,38 @@ jar { // destinationDirectory = file('C:\\Users\\notni\\OneDrive\\Desktop\\paper_1.21.8\\plugins') } +shadowJar { + archiveBaseName.set("SmartSpawner") + archiveVersion.set("${version}") + archiveClassifier.set("") + + from { project(':api').sourceSets.main.output } + + // Only include shade configuration dependencies + configurations = [project.configurations.shade] + + // Relocate shaded dependencies to avoid conflicts with other plugins + relocate 'com.zaxxer.hikari', 'github.nighter.smartspawner.libs.hikari' + relocate 'org.mariadb.jdbc', 'github.nighter.smartspawner.libs.mariadb' + // NOTE: SQLite JDBC cannot be relocated - it uses JNI native libraries + + exclude 'META-INF/*.RSA', 'META-INF/*.SF', 'META-INF/*.DSA' + // Exclude unnecessary files from dependencies + exclude 'META-INF/maven/**' + exclude 'META-INF/MANIFEST.MF' + exclude 'META-INF/LICENSE*' + exclude 'META-INF/NOTICE*' + + // Merge with main source output + from sourceSets.main.output + + // Exclude slf4j as it's provided by Paper/Bukkit + exclude 'org/slf4j/**' +} + +// Make shadowJar the default build artifact +build.dependsOn shadowJar + processResources { def props = [version: version] inputs.properties props diff --git a/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java b/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java index 738e0b1a..8f534cd7 100644 --- a/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java +++ b/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java @@ -9,6 +9,7 @@ import github.nighter.smartspawner.commands.list.gui.management.SpawnerManagementHandler; import github.nighter.smartspawner.commands.list.gui.management.SpawnerManagementGUI; import github.nighter.smartspawner.commands.list.gui.adminstacker.AdminStackerHandler; +import github.nighter.smartspawner.commands.list.gui.serverselection.ServerSelectionHandler; import github.nighter.smartspawner.commands.prices.PricesGUI; import github.nighter.smartspawner.spawner.config.SpawnerSettingsConfig; import github.nighter.smartspawner.spawner.config.ItemSpawnerSettingsConfig; @@ -44,6 +45,12 @@ import github.nighter.smartspawner.spawner.data.SpawnerManager; import github.nighter.smartspawner.spawner.sell.SpawnerSellManager; import github.nighter.smartspawner.spawner.data.SpawnerFileHandler; +import github.nighter.smartspawner.spawner.data.storage.SpawnerStorage; +import github.nighter.smartspawner.spawner.data.storage.StorageMode; +import github.nighter.smartspawner.spawner.data.database.DatabaseManager; +import github.nighter.smartspawner.spawner.data.database.SpawnerDatabaseHandler; +import github.nighter.smartspawner.spawner.data.database.SqliteToMySqlMigration; +import github.nighter.smartspawner.spawner.data.database.YamlToDatabaseMigration; import github.nighter.smartspawner.spawner.config.SpawnerMobHeadTexture; import github.nighter.smartspawner.spawner.lootgen.SpawnerLootGenerator; import github.nighter.smartspawner.spawner.data.WorldEventHandler; @@ -107,6 +114,8 @@ public class SmartSpawner extends JavaPlugin implements SmartSpawnerPlugin { // Core managers private SpawnerFileHandler spawnerFileHandler; + private SpawnerStorage spawnerStorage; + private DatabaseManager databaseManager; private SpawnerManager spawnerManager; private HopperHandler hopperHandler; private SpawnerLocationLockManager spawnerLocationLockManager; @@ -128,6 +137,7 @@ public class SmartSpawner extends JavaPlugin implements SmartSpawnerPlugin { private SpawnerListGUI spawnerListGUI; private SpawnerManagementHandler spawnerManagementHandler; private AdminStackerHandler adminStackerHandler; + private ServerSelectionHandler serverSelectionHandler; private PricesGUI pricesGUI; // Logging system @@ -262,7 +272,9 @@ private void initializeEconomyComponents() { } private void initializeCoreComponents() { - this.spawnerFileHandler = new SpawnerFileHandler(this); + // Initialize storage based on configured mode + initializeStorage(); + this.spawnerManager = new SpawnerManager(this); this.spawnerLocationLockManager = new SpawnerLocationLockManager(this); this.spawnerManager.reloadAllHolograms(); @@ -274,11 +286,85 @@ private void initializeCoreComponents() { this.spawnerLootGenerator = new SpawnerLootGenerator(this); this.spawnerSellManager = new SpawnerSellManager(this); this.rangeChecker = new SpawnerRangeChecker(this); - + // Initialize FormUI components only if Floodgate is available initializeFormUIComponents(); } + private void initializeStorage() { + String modeStr = getConfig().getString("database.mode", "YAML").toUpperCase(); + StorageMode mode; + try { + mode = StorageMode.valueOf(modeStr); + } catch (IllegalArgumentException e) { + getLogger().warning("Invalid storage mode '" + modeStr + "', defaulting to YAML"); + mode = StorageMode.YAML; + } + + if (mode == StorageMode.MYSQL || mode == StorageMode.SQLITE) { + String dbType = mode == StorageMode.MYSQL ? "MySQL/MariaDB" : "SQLite"; + getLogger().info("Initializing " + dbType + " database storage mode..."); + this.databaseManager = new DatabaseManager(this, mode); + + if (databaseManager.initialize()) { + SpawnerDatabaseHandler dbHandler = new SpawnerDatabaseHandler(this, databaseManager); + if (dbHandler.initialize()) { + this.spawnerStorage = dbHandler; + + // Check if migration is enabled in config + boolean migrateFromLocal = getConfig().getBoolean("database.migrate_from_local", true); + + if (migrateFromLocal) { + // Check for YAML migration (YAML -> MySQL or YAML -> SQLite) + YamlToDatabaseMigration yamlMigration = new YamlToDatabaseMigration(this, databaseManager); + if (yamlMigration.needsMigration()) { + getLogger().info("YAML data detected, starting migration to " + dbType + "..."); + if (yamlMigration.migrate()) { + getLogger().info("YAML migration completed successfully!"); + } else { + getLogger().warning("YAML migration completed with some errors. Check logs for details."); + } + } + + // Check for SQLite to MySQL migration (only when mode is MYSQL) + if (mode == StorageMode.MYSQL) { + SqliteToMySqlMigration sqliteMigration = new SqliteToMySqlMigration(this, databaseManager); + if (sqliteMigration.needsMigration()) { + getLogger().info("SQLite data detected, starting migration to MySQL..."); + if (sqliteMigration.migrate()) { + getLogger().info("SQLite to MySQL migration completed successfully!"); + } else { + getLogger().warning("SQLite migration completed with some errors. Check logs for details."); + } + } + } + } else { + debug("Local data migration is disabled in config."); + } + + getLogger().info(dbType + " database storage initialized successfully."); + } else { + getLogger().severe("Failed to initialize database handler, falling back to YAML"); + databaseManager.shutdown(); + databaseManager = null; + initializeYamlStorage(); + } + } else { + getLogger().severe("Failed to initialize database connection, falling back to YAML"); + databaseManager = null; + initializeYamlStorage(); + } + } else { + initializeYamlStorage(); + } + } + + private void initializeYamlStorage() { + this.spawnerFileHandler = new SpawnerFileHandler(this); + this.spawnerStorage = spawnerFileHandler; + getLogger().info("Using YAML file storage mode."); + } + private void initializeFormUIComponents() { // Check if FormUI is enabled in config boolean formUIEnabled = getConfig().getBoolean("bedrock_support.enable_formui", true); @@ -353,6 +439,7 @@ private void registerListeners() { pm.registerEvents(spawnerListGUI, this); pm.registerEvents(spawnerManagementHandler, this); pm.registerEvents(adminStackerHandler, this); + pm.registerEvents(serverSelectionHandler, this); pm.registerEvents(pricesGUI, this); // Register logging listener @@ -369,6 +456,7 @@ private void setupCommand() { this.spawnerListGUI = new SpawnerListGUI(this); this.spawnerManagementHandler = new SpawnerManagementHandler(this, listSubCommand); this.adminStackerHandler = new AdminStackerHandler(this, new SpawnerManagementGUI(this)); + this.serverSelectionHandler = new ServerSelectionHandler(this, listSubCommand); this.pricesGUI = new PricesGUI(this); } @@ -439,8 +527,14 @@ public void onDisable() { private void saveAndCleanup() { if (spawnerManager != null) { try { - if (spawnerFileHandler != null) { - spawnerFileHandler.shutdown(); + // Use the storage interface for shutdown + if (spawnerStorage != null) { + spawnerStorage.shutdown(); + } + + // Shutdown database manager if active + if (databaseManager != null) { + databaseManager.shutdown(); } // Clean up the spawner manager @@ -453,7 +547,7 @@ private void saveAndCleanup() { if (itemPriceManager != null) { itemPriceManager.cleanup(); } - + // Shutdown logging system if (spawnerActionLogger != null) { spawnerActionLogger.shutdown(); diff --git a/core/src/main/java/github/nighter/smartspawner/commands/list/ListSubCommand.java b/core/src/main/java/github/nighter/smartspawner/commands/list/ListSubCommand.java index 8bada012..61ddb01b 100644 --- a/core/src/main/java/github/nighter/smartspawner/commands/list/ListSubCommand.java +++ b/core/src/main/java/github/nighter/smartspawner/commands/list/ListSubCommand.java @@ -4,14 +4,18 @@ import github.nighter.smartspawner.SmartSpawner; import github.nighter.smartspawner.nms.VersionInitializer; import github.nighter.smartspawner.commands.BaseSubCommand; +import github.nighter.smartspawner.commands.list.gui.CrossServerSpawnerData; import github.nighter.smartspawner.commands.list.gui.list.enums.FilterOption; import github.nighter.smartspawner.commands.list.gui.list.enums.SortOption; import github.nighter.smartspawner.commands.list.gui.list.SpawnerListHolder; import github.nighter.smartspawner.commands.list.gui.list.UserPreferenceCache; import github.nighter.smartspawner.commands.list.gui.worldselection.WorldSelectionHolder; +import github.nighter.smartspawner.commands.list.gui.serverselection.ServerSelectionHolder; import github.nighter.smartspawner.commands.list.gui.management.SpawnerManagementGUI; import github.nighter.smartspawner.language.LanguageManager; import github.nighter.smartspawner.language.MessageService; +import github.nighter.smartspawner.spawner.data.database.SpawnerDatabaseHandler; +import github.nighter.smartspawner.spawner.data.storage.StorageMode; import github.nighter.smartspawner.spawner.properties.SpawnerData; import github.nighter.smartspawner.spawner.data.SpawnerManager; import github.nighter.smartspawner.spawner.config.SpawnerMobHeadTexture; @@ -67,10 +71,225 @@ public int execute(CommandContext context) { Player player = getPlayer(context.getSource().getSender()); - openWorldSelectionGUI(player); + // Check if cross-server mode is enabled + if (isCrossServerEnabled()) { + openServerSelectionGUI(player); + } else { + openWorldSelectionGUI(player); + } return 1; } + /** + * Check if cross-server sync is enabled. + * Requires MYSQL mode AND sync_across_servers = true + * (SQLite is local-only and does not support cross-server sync) + */ + public boolean isCrossServerEnabled() { + String modeStr = plugin.getConfig().getString("database.mode", "YAML").toUpperCase(); + try { + StorageMode mode = StorageMode.valueOf(modeStr); + if (mode != StorageMode.MYSQL) { + return false; + } + } catch (IllegalArgumentException e) { + return false; + } + return plugin.getConfig().getBoolean("database.sync_across_servers", false); + } + + /** + * Get the current server name from config. + */ + public String getCurrentServerName() { + return plugin.getConfig().getString("database.server_name", "server1"); + } + + /** + * Open the server selection GUI (async database query). + */ + public void openServerSelectionGUI(Player player) { + if (!player.hasPermission("smartspawner.command.list")) { + messageService.sendMessage(player, "no_permission"); + return; + } + + SpawnerDatabaseHandler dbHandler = getDbHandler(); + if (dbHandler == null) { + // Fallback to local world selection + openWorldSelectionGUI(player); + return; + } + + player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1.0f, 1.0f); + + // Async query for server names + dbHandler.getDistinctServerNamesAsync(servers -> { + if (servers.isEmpty()) { + messageService.sendMessage(player, "no_spawners_found"); + return; + } + + // Calculate inventory size + int size = Math.max(9, (int) Math.ceil(servers.size() / 7.0) * 9); + size = Math.min(54, size); // Max 54 slots + + String title = languageManager.getGuiTitle("gui_title_server_selection"); + if (title == null || title.isEmpty()) { + title = ChatColor.DARK_GRAY + "Select Server"; + } + + Inventory inv = Bukkit.createInventory(new ServerSelectionHolder(), size, title); + + String currentServer = getCurrentServerName(); + int slot = 0; + + for (String serverName : servers) { + if (slot >= size) break; + + // Skip border slots for nicer layout + while (slot < size && (slot % 9 == 0 || slot % 9 == 8)) { + slot++; + } + if (slot >= size) break; + + Material material = serverName.equals(currentServer) ? Material.EMERALD_BLOCK : Material.IRON_BLOCK; + ItemStack serverItem = createServerButton(serverName, material, serverName.equals(currentServer)); + inv.setItem(slot, serverItem); + slot++; + } + + player.openInventory(inv); + }); + } + + private ItemStack createServerButton(String serverName, Material material, boolean isCurrentServer) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + String displayName = (isCurrentServer ? ChatColor.GREEN : ChatColor.GOLD) + serverName; + meta.setDisplayName(displayName); + + List lore = new ArrayList<>(); + if (isCurrentServer) { + lore.add(ChatColor.GRAY + "Current Server"); + } + lore.add(ChatColor.YELLOW + "Click to view spawners"); + meta.setLore(lore); + + item.setItemMeta(meta); + } + return item; + } + + /** + * Open world selection for a specific server (async for remote servers). + */ + public void openWorldSelectionGUIForServer(Player player, String targetServer) { + if (!player.hasPermission("smartspawner.command.list")) { + messageService.sendMessage(player, "no_permission"); + return; + } + + String currentServer = getCurrentServerName(); + + // If it's the current server, use local data + if (targetServer.equals(currentServer)) { + openWorldSelectionGUI(player); + return; + } + + // For remote servers, query async + SpawnerDatabaseHandler dbHandler = getDbHandler(); + if (dbHandler == null) { + messageService.sendMessage(player, "database_error"); + return; + } + + player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1.0f, 1.0f); + + dbHandler.getWorldsForServerAsync(targetServer, worldCounts -> { + if (worldCounts.isEmpty()) { + messageService.sendMessage(player, "no_spawners_found"); + return; + } + + int size = Math.max(27, (int) Math.ceil((worldCounts.size() + 2) / 7.0) * 9); + size = Math.min(54, size); + + Map titlePlaceholders = new HashMap<>(); + titlePlaceholders.put("server", targetServer); + String title = languageManager.getGuiTitle("gui_title_world_selection_server", titlePlaceholders); + if (title == null || title.isEmpty()) { + title = ChatColor.DARK_GRAY + "Worlds - " + targetServer; + } + + Inventory inv = Bukkit.createInventory( + new WorldSelectionHolder(targetServer), + size, title + ); + + int slot = 10; + for (Map.Entry entry : worldCounts.entrySet()) { + if (slot >= size - 9) break; + + // Skip border slots + if (slot % 9 == 0 || slot % 9 == 8) { + slot++; + continue; + } + + String worldName = entry.getKey(); + int count = entry.getValue(); + + Material material = getMaterialForWorldName(worldName); + ItemStack worldItem = createRemoteWorldButton(worldName, material, count, targetServer); + inv.setItem(slot, worldItem); + slot++; + } + + // Back button + ItemStack backButton = createNavigationButton(Material.RED_STAINED_GLASS_PANE, "navigation.back"); + inv.setItem(size - 5, backButton); + + player.openInventory(inv); + }); + } + + private ItemStack createRemoteWorldButton(String worldName, Material material, int spawnerCount, String serverName) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(ChatColor.GREEN + formatWorldName(worldName)); + + List lore = new ArrayList<>(); + lore.add(ChatColor.GRAY + "Server: " + ChatColor.WHITE + serverName); + lore.add(ChatColor.GRAY + "Spawners: " + ChatColor.WHITE + spawnerCount); + lore.add(""); + lore.add(ChatColor.YELLOW + "Click to view spawners"); + meta.setLore(lore); + + item.setItemMeta(meta); + } + return item; + } + + private Material getMaterialForWorldName(String worldName) { + if (worldName.contains("nether")) { + return Material.NETHERRACK; + } else if (worldName.contains("end")) { + return Material.END_STONE; + } + return Material.GRASS_BLOCK; + } + + private SpawnerDatabaseHandler getDbHandler() { + if (plugin.getSpawnerStorage() instanceof SpawnerDatabaseHandler dbHandler) { + return dbHandler; + } + return null; + } + // World selection GUI logic (unchanged) public void openWorldSelectionGUI(Player player) { if (!player.hasPermission("smartspawner.command.list")) { @@ -154,6 +373,12 @@ public void openWorldSelectionGUI(Player player) { } } + // Add back button if cross-server mode is enabled + if (isCrossServerEnabled()) { + ItemStack backButton = createNavigationButton(Material.RED_STAINED_GLASS_PANE, "navigation.back"); + inv.setItem(size - 5, backButton); // Bottom center + } + player.openInventory(inv); } @@ -497,6 +722,148 @@ private ItemStack createSpawnerInfoItem(SpawnerData spawner) { public void openSpawnerManagementGUI(Player player, String spawnerId, String worldName, int listPage) { spawnerManagementGUI.openManagementMenu(player, spawnerId, worldName, listPage); } + + public void openSpawnerManagementGUI(Player player, String spawnerId, String worldName, int listPage, String targetServer) { + spawnerManagementGUI.openManagementMenu(player, spawnerId, worldName, listPage, targetServer); + } + + /** + * Open spawner list GUI for a remote server (async database query). + */ + public void openSpawnerListGUIForServer(Player player, String targetServer, String worldName, int page) { + // Use default filter and sort + openSpawnerListGUIForServer(player, targetServer, worldName, page, FilterOption.ALL, SortOption.DEFAULT); + } + + /** + * Open spawner list GUI for a remote server with filter and sort options. + */ + public void openSpawnerListGUIForServer(Player player, String targetServer, String worldName, int page, + FilterOption filter, SortOption sort) { + if (!player.hasPermission("smartspawner.command.list")) { + messageService.sendMessage(player, "no_permission"); + return; + } + + String currentServer = getCurrentServerName(); + + // If it's the current server, use local data + if (targetServer.equals(currentServer)) { + openSpawnerListGUI(player, worldName, page, filter, sort); + return; + } + + // For remote servers, query async + SpawnerDatabaseHandler dbHandler = getDbHandler(); + if (dbHandler == null) { + messageService.sendMessage(player, "database_error"); + return; + } + + player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1.0f, 1.0f); + + final int requestedPage = page; + final FilterOption finalFilter = filter; + final SortOption finalSort = sort; + dbHandler.getCrossServerSpawnersAsync(targetServer, worldName, filter.name(), sort.name(), spawners -> { + if (spawners.isEmpty()) { + messageService.sendMessage(player, "no_spawners_found"); + return; + } + + int totalPages = (int) Math.ceil((double) spawners.size() / SPAWNERS_PER_PAGE); + int currentPage = Math.max(1, Math.min(requestedPage, totalPages)); + + String worldTitle = formatWorldName(worldName); + + Map titlePlaceholders = new HashMap<>(); + titlePlaceholders.put("world", worldTitle); + titlePlaceholders.put("current", String.valueOf(currentPage)); + titlePlaceholders.put("total", String.valueOf(totalPages)); + + String title = languageManager.getGuiTitle("gui_title_spawner_list", titlePlaceholders); + + Inventory inv = Bukkit.createInventory( + new SpawnerListHolder(currentPage, totalPages, worldName, finalFilter, finalSort, targetServer), + 54, title + ); + + // Calculate start and end indices for current page + int startIndex = (currentPage - 1) * SPAWNERS_PER_PAGE; + int endIndex = Math.min(startIndex + SPAWNERS_PER_PAGE, spawners.size()); + + // Populate inventory with spawners + for (int i = startIndex; i < endIndex; i++) { + CrossServerSpawnerData spawner = spawners.get(i); + inv.addItem(createCrossServerSpawnerItem(spawner, targetServer)); + } + + // Add navigation buttons + // Previous page + if (currentPage > 1) { + inv.setItem(45, createNavigationButton(Material.SPECTRAL_ARROW, "navigation.previous_page")); + } + + // Filter button (slot 48) + addControlButtons(inv, finalFilter, finalSort); + + // Back button + inv.setItem(49, createNavigationButton(Material.RED_STAINED_GLASS_PANE, "navigation.back")); + + // Next page + if (currentPage < totalPages) { + inv.setItem(53, createNavigationButton(Material.SPECTRAL_ARROW, "navigation.next_page")); + } + + player.openInventory(inv); + }); + } + + private ItemStack createCrossServerSpawnerItem(CrossServerSpawnerData spawner, String serverName) { + EntityType entityType = spawner.getEntityType(); + + // Prepare all placeholders + Map placeholders = new HashMap<>(); + placeholders.put("id", spawner.getSpawnerId()); + placeholders.put("entity", languageManager.getFormattedMobName(entityType)); + placeholders.put("size", String.valueOf(spawner.getStackSize())); + if (!spawner.isActive()) { + placeholders.put("status_color", "&#ff6b6b"); + placeholders.put("status_text", "Inactive"); + } else { + placeholders.put("status_color", "�E689"); + placeholders.put("status_text", "Active"); + } + placeholders.put("x", String.valueOf(spawner.getLocX())); + placeholders.put("y", String.valueOf(spawner.getLocY())); + placeholders.put("z", String.valueOf(spawner.getLocZ())); + placeholders.put("last_player", "N/A"); + + ItemStack spawnerItem; + + if (entityType == null) { + spawnerItem = new ItemStack(Material.SPAWNER); + spawnerItem.editMeta(meta -> { + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS, ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_UNBREAKABLE); + meta.setDisplayName(languageManager.getGuiItemName("spawner_item_list.name", placeholders)); + List lore = new ArrayList<>(Arrays.asList(languageManager.getGuiItemLore("spawner_item_list.lore", placeholders))); + lore.add(""); + lore.add(ChatColor.DARK_GRAY + "Server: " + ChatColor.WHITE + serverName); + meta.setLore(lore); + }); + } else { + spawnerItem = SpawnerMobHeadTexture.getCustomHead(entityType, meta -> { + meta.setDisplayName(languageManager.getGuiItemName("spawner_item_list.name", placeholders)); + List lore = new ArrayList<>(Arrays.asList(languageManager.getGuiItemLore("spawner_item_list.lore", placeholders))); + lore.add(""); + lore.add(ChatColor.DARK_GRAY + "Server: " + ChatColor.WHITE + serverName); + meta.setLore(lore); + }); + } + + VersionInitializer.hideTooltip(spawnerItem); + return spawnerItem; + } public FilterOption getUserFilter(Player player, String worldName) { return userPreferenceCache.getUserFilter(player, worldName); } diff --git a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/CrossServerSpawnerData.java b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/CrossServerSpawnerData.java new file mode 100644 index 00000000..9fb7e0b2 --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/CrossServerSpawnerData.java @@ -0,0 +1,99 @@ +package github.nighter.smartspawner.commands.list.gui; + +import org.bukkit.entity.EntityType; + +/** + * Lightweight data class for spawner information from remote servers. + * Used when viewing spawners across servers in the list GUI. + * Does not require actual Bukkit Location/World objects since + * the spawner exists on a different server. + */ +public class CrossServerSpawnerData { + private final String spawnerId; + private final String serverName; + private final String worldName; + private final int locX; + private final int locY; + private final int locZ; + private final EntityType entityType; + private final int stackSize; + private final boolean active; + private final String lastInteractedPlayer; + private final int storedExp; + private final long totalItems; + + public CrossServerSpawnerData(String spawnerId, String serverName, String worldName, + int locX, int locY, int locZ, EntityType entityType, + int stackSize, boolean active, String lastInteractedPlayer, + int storedExp, long totalItems) { + this.spawnerId = spawnerId; + this.serverName = serverName; + this.worldName = worldName; + this.locX = locX; + this.locY = locY; + this.locZ = locZ; + this.entityType = entityType; + this.stackSize = stackSize; + this.active = active; + this.lastInteractedPlayer = lastInteractedPlayer; + this.storedExp = storedExp; + this.totalItems = totalItems; + } + + public String getSpawnerId() { + return spawnerId; + } + + public String getServerName() { + return serverName; + } + + public String getWorldName() { + return worldName; + } + + public int getLocX() { + return locX; + } + + public int getLocY() { + return locY; + } + + public int getLocZ() { + return locZ; + } + + public EntityType getEntityType() { + return entityType; + } + + public int getStackSize() { + return stackSize; + } + + public boolean isActive() { + return active; + } + + public String getLastInteractedPlayer() { + return lastInteractedPlayer; + } + + public int getStoredExp() { + return storedExp; + } + + public long getTotalItems() { + return totalItems; + } + + /** + * Check if this spawner is on the current server. + * @param currentServerName The name of the current server + * @return true if this spawner is on the current server + */ + public boolean isLocalServer(String currentServerName) { + return serverName.equals(currentServerName); + } +} diff --git a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/adminstacker/AdminStackerHandler.java b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/adminstacker/AdminStackerHandler.java index ec275dcd..fd4e826c 100644 --- a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/adminstacker/AdminStackerHandler.java +++ b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/adminstacker/AdminStackerHandler.java @@ -5,11 +5,14 @@ import github.nighter.smartspawner.language.MessageService; import github.nighter.smartspawner.spawner.properties.SpawnerData; import github.nighter.smartspawner.spawner.data.SpawnerManager; +import github.nighter.smartspawner.spawner.data.database.SpawnerDatabaseHandler; +import github.nighter.smartspawner.spawner.data.storage.SpawnerStorage; import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; import java.util.HashMap; import java.util.Map; @@ -94,7 +97,7 @@ private void handleStackChange(Player player, SpawnerData spawner, String worldN } int newStackSize = spawner.getStackSize() + change; - + // Ensure stack size is within valid bounds if (newStackSize < 1) { newStackSize = 1; @@ -107,7 +110,10 @@ private void handleStackChange(Player player, SpawnerData spawner, String worldN // Update the spawner stack size spawner.setStackSize(newStackSize); - + + // Mark spawner as modified for database save + spawnerManager.markSpawnerModified(spawner.getSpawnerId()); + // Track interaction spawner.updateLastInteractedPlayer(player.getName()); player.playSound(player.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f); @@ -116,4 +122,106 @@ private void handleStackChange(Player player, SpawnerData spawner, String worldN AdminStackerUI adminStackerUI = new AdminStackerUI(plugin); adminStackerUI.openAdminStackerGui(player, spawner, worldName, listPage); } + + // ===== Remote Admin Stacker Handler ===== + + @EventHandler + public void onRemoteAdminStackerClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder(false) instanceof RemoteAdminStackerHolder holder)) return; + if (!(event.getWhoClicked() instanceof Player player)) return; + + event.setCancelled(true); + if (event.getCurrentItem() == null) return; + + int slot = event.getSlot(); + handleRemoteClick(player, holder, event.getInventory(), slot); + } + + private void handleRemoteClick(Player player, RemoteAdminStackerHolder holder, Inventory inventory, int slot) { + if (slot == BACK_SLOT) { + // Save changes to database and return to management GUI + saveRemoteStackChanges(player, holder); + return; + } + + if (slot == SPAWNER_INFO_SLOT) { + // Do nothing for info slot + return; + } + + // Check if it's a decrease slot + for (int i = 0; i < DECREASE_SLOTS.length; i++) { + if (slot == DECREASE_SLOTS[i]) { + handleRemoteStackChange(player, holder, inventory, -STACK_AMOUNTS[i]); + return; + } + } + + // Check if it's an increase slot + for (int i = 0; i < INCREASE_SLOTS.length; i++) { + if (slot == INCREASE_SLOTS[i]) { + handleRemoteStackChange(player, holder, inventory, STACK_AMOUNTS[i]); + return; + } + } + } + + private void handleRemoteStackChange(Player player, RemoteAdminStackerHolder holder, + Inventory inventory, int change) { + if (!player.hasPermission("smartspawner.stack")) { + messageService.sendMessage(player, "no_permission"); + return; + } + + // Adjust the stack size in the holder (not saved yet) + holder.adjustStackSize(change); + + // Play feedback sound + player.playSound(player.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.0f); + + // Refresh the GUI to show updated values + AdminStackerUI adminStackerUI = new AdminStackerUI(plugin); + adminStackerUI.refreshRemoteStackerGui(inventory, holder); + } + + private void saveRemoteStackChanges(Player player, RemoteAdminStackerHolder holder) { + SpawnerStorage storage = plugin.getSpawnerStorage(); + if (!(storage instanceof SpawnerDatabaseHandler dbHandler)) { + messageService.sendMessage(player, "database_error"); + return; + } + + String targetServer = holder.getTargetServer(); + String spawnerId = holder.getSpawnerId(); + int newStackSize = holder.getCurrentStackSize(); + int originalSize = holder.getSpawnerData().getStackSize(); + + // Only save if changed + if (newStackSize != originalSize) { + player.sendMessage("§eSaving stack size changes..."); + + dbHandler.updateRemoteSpawnerStackSizeAsync(targetServer, spawnerId, newStackSize, success -> { + if (success) { + Map placeholders = new HashMap<>(); + placeholders.put("old", String.valueOf(originalSize)); + placeholders.put("new", String.valueOf(newStackSize)); + player.sendMessage("§aStack size updated from " + originalSize + " to " + newStackSize); + player.sendMessage("§e[Note] Changes will sync to " + targetServer + " on next refresh."); + player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1.0f, 1.0f); + } else { + player.sendMessage("§cFailed to update stack size. Spawner may have been removed."); + player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f); + } + + // Return to management GUI + managementGUI.openManagementMenu(player, spawnerId, holder.getWorldName(), + holder.getListPage(), targetServer); + }); + } else { + // No changes, just go back + player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1.0f, 1.0f); + managementGUI.openManagementMenu(player, spawnerId, holder.getWorldName(), + holder.getListPage(), targetServer); + } + } } diff --git a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/adminstacker/AdminStackerUI.java b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/adminstacker/AdminStackerUI.java index c8497f8a..607ce0b0 100644 --- a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/adminstacker/AdminStackerUI.java +++ b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/adminstacker/AdminStackerUI.java @@ -1,10 +1,12 @@ package github.nighter.smartspawner.commands.list.gui.adminstacker; import github.nighter.smartspawner.SmartSpawner; +import github.nighter.smartspawner.commands.list.gui.CrossServerSpawnerData; import github.nighter.smartspawner.nms.VersionInitializer; import github.nighter.smartspawner.spawner.properties.SpawnerData; import github.nighter.smartspawner.language.LanguageManager; import org.bukkit.Bukkit; +import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.inventory.Inventory; @@ -12,7 +14,9 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Arrays; @@ -42,6 +46,92 @@ public void openAdminStackerGui(Player player, SpawnerData spawner, String world player.openInventory(gui); } + public void openRemoteAdminStackerGui(Player player, CrossServerSpawnerData spawnerData, + String targetServer, String worldName, int listPage) { + if (player == null || spawnerData == null) { + return; + } + String title = languageManager.getGuiTitle("gui_title_stacker") + " §7[Remote]"; + RemoteAdminStackerHolder holder = new RemoteAdminStackerHolder(spawnerData, targetServer, worldName, listPage); + Inventory gui = Bukkit.createInventory(holder, GUI_SIZE, title); + populateRemoteStackerGui(gui, holder); + player.openInventory(gui); + } + + public void refreshRemoteStackerGui(Inventory gui, RemoteAdminStackerHolder holder) { + populateRemoteStackerGui(gui, holder); + } + + private void populateRemoteStackerGui(Inventory gui, RemoteAdminStackerHolder holder) { + CrossServerSpawnerData spawnerData = holder.getSpawnerData(); + int currentSize = holder.getCurrentStackSize(); + + for (int i = 0; i < STACK_AMOUNTS.length; i++) { + gui.setItem(DECREASE_SLOTS[i], createRemoteActionButton("remove", spawnerData, currentSize, STACK_AMOUNTS[i])); + } + for (int i = 0; i < STACK_AMOUNTS.length; i++) { + gui.setItem(INCREASE_SLOTS[i], createRemoteActionButton("add", spawnerData, currentSize, STACK_AMOUNTS[i])); + } + gui.setItem(SPAWNER_INFO_SLOT, createRemoteSpawnerInfoButton(spawnerData, currentSize)); + gui.setItem(BACK_SLOT, createSaveAndBackButton()); + } + + private ItemStack createRemoteActionButton(String action, CrossServerSpawnerData spawnerData, + int currentSize, int amount) { + Map placeholders = createRemotePlaceholders(spawnerData, currentSize, amount); + String name = languageManager.getGuiItemName("button_" + action + ".name", placeholders); + String[] lore = languageManager.getGuiItemLore("button_" + action + ".lore", placeholders); + Material material = action.equals("add") ? Material.LIME_STAINED_GLASS_PANE : Material.RED_STAINED_GLASS_PANE; + ItemStack button = createButton(material, name, lore); + button.setAmount(Math.max(1, Math.min(amount, 64))); + return button; + } + + private ItemStack createRemoteSpawnerInfoButton(CrossServerSpawnerData spawnerData, int currentSize) { + Map placeholders = createRemotePlaceholders(spawnerData, currentSize, 0); + String name = languageManager.getGuiItemName("button_spawner.name", placeholders); + List lore = new ArrayList<>(); + lore.add(ChatColor.GRAY + "Current Stack: " + ChatColor.WHITE + currentSize); + lore.add(ChatColor.GRAY + "Original: " + ChatColor.WHITE + spawnerData.getStackSize()); + lore.add(""); + lore.add(ChatColor.YELLOW + "Remote Server: " + spawnerData.getServerName()); + lore.add(ChatColor.GRAY + "Changes save when you click Back"); + return createButtonWithLore(Material.SPAWNER, name, lore); + } + + private ItemStack createSaveAndBackButton() { + String name = ChatColor.GREEN + "Save & Back"; + List lore = new ArrayList<>(); + lore.add(ChatColor.GRAY + "Click to save changes"); + lore.add(ChatColor.GRAY + "and return to management menu"); + return createButtonWithLore(Material.LIME_STAINED_GLASS_PANE, name, lore); + } + + private Map createRemotePlaceholders(CrossServerSpawnerData spawnerData, + int currentSize, int amount) { + Map placeholders = new HashMap<>(); + placeholders.put("amount", String.valueOf(amount)); + placeholders.put("plural", amount > 1 ? "s" : ""); + placeholders.put("stack_size", String.valueOf(currentSize)); + placeholders.put("max_stack_size", "∞"); // Remote spawners don't have local max + placeholders.put("entity", languageManager.getFormattedMobName(spawnerData.getEntityType())); + placeholders.put("ᴇɴᴛɪᴛʏ", languageManager.getSmallCaps(placeholders.get("entity"))); + return placeholders; + } + + private ItemStack createButtonWithLore(Material material, String name, List lore) { + ItemStack button = new ItemStack(material); + ItemMeta meta = button.getItemMeta(); + if (meta != null) { + meta.setDisplayName(name); + meta.setLore(lore); + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS, ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_UNBREAKABLE); + button.setItemMeta(meta); + } + VersionInitializer.hideTooltip(button); + return button; + } + private void populateStackerGui(Inventory gui, SpawnerData spawner) { for (int i = 0; i < STACK_AMOUNTS.length; i++) { gui.setItem(DECREASE_SLOTS[i], createActionButton("remove", spawner, STACK_AMOUNTS[i])); diff --git a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/adminstacker/RemoteAdminStackerHolder.java b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/adminstacker/RemoteAdminStackerHolder.java new file mode 100644 index 00000000..3cc0e4be --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/adminstacker/RemoteAdminStackerHolder.java @@ -0,0 +1,44 @@ +package github.nighter.smartspawner.commands.list.gui.adminstacker; + +import github.nighter.smartspawner.commands.list.gui.CrossServerSpawnerData; +import lombok.Getter; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; + +/** + * Inventory holder for the admin stacker GUI when managing a remote server's spawner + */ +@Getter +public class RemoteAdminStackerHolder implements InventoryHolder { + private final CrossServerSpawnerData spawnerData; + private final String targetServer; + private final String worldName; + private final int listPage; + private int currentStackSize; + + public RemoteAdminStackerHolder(CrossServerSpawnerData spawnerData, String targetServer, + String worldName, int listPage) { + this.spawnerData = spawnerData; + this.targetServer = targetServer; + this.worldName = worldName; + this.listPage = listPage; + this.currentStackSize = spawnerData.getStackSize(); + } + + public void adjustStackSize(int amount) { + this.currentStackSize = Math.max(1, this.currentStackSize + amount); + } + + public void setCurrentStackSize(int size) { + this.currentStackSize = Math.max(1, size); + } + + public String getSpawnerId() { + return spawnerData.getSpawnerId(); + } + + @Override + public Inventory getInventory() { + return null; + } +} diff --git a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/list/SpawnerListGUI.java b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/list/SpawnerListGUI.java index 67e1ff4e..6cb9f875 100644 --- a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/list/SpawnerListGUI.java +++ b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/list/SpawnerListGUI.java @@ -43,7 +43,7 @@ public SpawnerListGUI(SmartSpawner plugin) { @EventHandler public void onWorldSelectionClick(InventoryClickEvent event) { - if (!(event.getInventory().getHolder(false) instanceof WorldSelectionHolder)) return; + if (!(event.getInventory().getHolder(false) instanceof WorldSelectionHolder holder)) return; if (!(event.getWhoClicked() instanceof Player player)) return; if (!player.hasPermission("smartspawner.command.list")) { @@ -56,7 +56,28 @@ public void onWorldSelectionClick(InventoryClickEvent event) { if (clickedItem == null || !clickedItem.hasItemMeta() || !clickedItem.getItemMeta().hasDisplayName()) return; String displayName = ChatColor.stripColor(clickedItem.getItemMeta().getDisplayName()); + String targetServer = holder.getTargetServer(); + boolean isRemote = holder.isRemoteServer(); + // Handle back button for world selection (both local and remote when cross-server is enabled) + if (clickedItem.getType() == Material.RED_STAINED_GLASS_PANE) { + // Go back to server selection + listSubCommand.openServerSelectionGUI(player); + return; + } + + // For remote servers, we need to use the async method + if (isRemote) { + // Extract world name from display name for remote servers + // The display name format is "World Name" or similar + String worldName = extractWorldNameFromDisplay(displayName); + if (worldName != null) { + listSubCommand.openSpawnerListGUIForServer(player, targetServer, worldName, 1); + } + return; + } + + // Local server handling (original logic) // Check for original layout slots first (for backward compatibility) if (event.getSlot() == 11 && displayName.equals(ChatColor.stripColor(languageManager.getGuiTitle("world_buttons.overworld.name")))) { listSubCommand.openSpawnerListGUI(player, "world", 1); @@ -90,6 +111,24 @@ public void onWorldSelectionClick(InventoryClickEvent event) { } } + /** + * Extract world name from display name for remote servers. + * Tries common world name patterns. + */ + private String extractWorldNameFromDisplay(String displayName) { + // Check if it matches known world display names + if (displayName.equalsIgnoreCase("Overworld") || displayName.equalsIgnoreCase("World")) { + return "world"; + } else if (displayName.equalsIgnoreCase("Nether") || displayName.equalsIgnoreCase("The Nether")) { + return "world_nether"; + } else if (displayName.equalsIgnoreCase("The End") || displayName.equalsIgnoreCase("End")) { + return "world_the_end"; + } + // For custom worlds, convert display name back to world name format + // "My Custom World" -> "my_custom_world" + return displayName.toLowerCase().replace(' ', '_'); + } + // Helper method to format world name (same as in listSubCommand) private String formatWorldName(String worldName) { // Convert something like "my_custom_world" to "My Custom World" @@ -116,50 +155,78 @@ public void onSpawnerListClick(InventoryClickEvent event) { int totalPages = holder.getTotalPages(); FilterOption currentFilter = holder.getFilterOption(); SortOption currentSort = holder.getSortType(); + String targetServer = holder.getTargetServer(); + boolean isRemote = holder.isRemoteServer(); - // Handle filter button click + // Handle filter button click (works for both local and remote) if (event.getSlot() == 48) { // Cycle to next filter option FilterOption nextFilter = currentFilter.getNextOption(); - // Save user preference when they change filter - listSubCommand.saveUserPreference(player, worldName, nextFilter, currentSort); + // Save user preference when they change filter (only for local) + if (!isRemote) { + listSubCommand.saveUserPreference(player, worldName, nextFilter, currentSort); + } - listSubCommand.openSpawnerListGUI(player, worldName, 1, nextFilter, currentSort); + if (isRemote) { + listSubCommand.openSpawnerListGUIForServer(player, targetServer, worldName, 1, nextFilter, currentSort); + } else { + listSubCommand.openSpawnerListGUI(player, worldName, 1, nextFilter, currentSort); + } return; } - // Handle sort button click + // Handle sort button click (works for both local and remote) if (event.getSlot() == 50) { // Cycle to next sort option SortOption nextSort = currentSort.getNextOption(); - // Save user preference when they change sort - listSubCommand.saveUserPreference(player, worldName, currentFilter, nextSort); + // Save user preference when they change sort (only for local) + if (!isRemote) { + listSubCommand.saveUserPreference(player, worldName, currentFilter, nextSort); + } - listSubCommand.openSpawnerListGUI(player, worldName, 1, currentFilter, nextSort); + if (isRemote) { + listSubCommand.openSpawnerListGUIForServer(player, targetServer, worldName, 1, currentFilter, nextSort); + } else { + listSubCommand.openSpawnerListGUI(player, worldName, 1, currentFilter, nextSort); + } return; } - // Handle navigation + // Handle navigation - works for both local and remote if (event.getSlot() == 45 && currentPage > 1) { // Previous page - listSubCommand.openSpawnerListGUI(player, worldName, currentPage - 1, currentFilter, currentSort); + if (isRemote) { + listSubCommand.openSpawnerListGUIForServer(player, targetServer, worldName, currentPage - 1); + } else { + listSubCommand.openSpawnerListGUI(player, worldName, currentPage - 1, currentFilter, currentSort); + } return; } if (event.getSlot() == 49) { - // Save preference before going back to world selection - listSubCommand.saveUserPreference(player, worldName, currentFilter, currentSort); + // Save preference before going back to world selection (only for local) + if (!isRemote) { + listSubCommand.saveUserPreference(player, worldName, currentFilter, currentSort); + } // Back to world selection - listSubCommand.openWorldSelectionGUI(player); + if (isRemote) { + listSubCommand.openWorldSelectionGUIForServer(player, targetServer); + } else { + listSubCommand.openWorldSelectionGUI(player); + } return; } if (event.getSlot() == 53 && currentPage < totalPages) { // Next page - listSubCommand.openSpawnerListGUI(player, worldName, currentPage + 1, currentFilter, currentSort); + if (isRemote) { + listSubCommand.openSpawnerListGUIForServer(player, targetServer, worldName, currentPage + 1); + } else { + listSubCommand.openSpawnerListGUI(player, worldName, currentPage + 1, currentFilter, currentSort); + } return; } @@ -203,14 +270,25 @@ private void handleSpawnerItemClick(Player player, ItemStack item, SpawnerListHo if (matcher.find()) { String spawnerId = matcher.group(1); - SpawnerData spawner = spawnerManager.getSpawnerById(spawnerId); - - if (spawner != null) { - // Open the management GUI instead of directly teleporting - listSubCommand.openSpawnerManagementGUI(player, spawnerId, - holder.getWorldName(), holder.getCurrentPage()); + String targetServer = holder.getTargetServer(); + boolean isRemote = holder.isRemoteServer(); + + // For remote servers, spawner data isn't available locally + if (isRemote) { + // Open management GUI with remote server context (actions will be disabled) + listSubCommand.openSpawnerManagementGUI(player, spawnerId, + holder.getWorldName(), holder.getCurrentPage(), targetServer); } else { - messageService.sendMessage(player, "spawner_not_found"); + // Local server - verify spawner exists + SpawnerData spawner = spawnerManager.getSpawnerById(spawnerId); + + if (spawner != null) { + // Open the management GUI + listSubCommand.openSpawnerManagementGUI(player, spawnerId, + holder.getWorldName(), holder.getCurrentPage(), null); + } else { + messageService.sendMessage(player, "spawner_not_found"); + } } } } diff --git a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/list/SpawnerListHolder.java b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/list/SpawnerListHolder.java index 608c8898..ffe2dcab 100644 --- a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/list/SpawnerListHolder.java +++ b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/list/SpawnerListHolder.java @@ -13,14 +13,28 @@ public class SpawnerListHolder implements InventoryHolder { private final String worldName; private final FilterOption filterOption; private final SortOption sortType; + private final String targetServer; public SpawnerListHolder(int currentPage, int totalPages, String worldName, FilterOption filterOption, SortOption sortType) { + this(currentPage, totalPages, worldName, filterOption, sortType, null); + } + + public SpawnerListHolder(int currentPage, int totalPages, String worldName, + FilterOption filterOption, SortOption sortType, String targetServer) { this.currentPage = currentPage; this.totalPages = totalPages; this.worldName = worldName; this.filterOption = filterOption; this.sortType = sortType; + this.targetServer = targetServer; + } + + /** + * Check if this list is showing spawners from a remote server. + */ + public boolean isRemoteServer() { + return targetServer != null; } @Override diff --git a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/management/SpawnerManagementGUI.java b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/management/SpawnerManagementGUI.java index d35b4607..20e84ac3 100644 --- a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/management/SpawnerManagementGUI.java +++ b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/management/SpawnerManagementGUI.java @@ -7,6 +7,7 @@ import github.nighter.smartspawner.spawner.properties.SpawnerData; import github.nighter.smartspawner.spawner.data.SpawnerManager; import org.bukkit.Bukkit; +import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.Sound; import org.bukkit.entity.Player; @@ -15,6 +16,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -38,22 +40,67 @@ public SpawnerManagementGUI(SmartSpawner plugin) { this.spawnerManager = plugin.getSpawnerManager(); } + /** + * Open management menu for a local spawner. + */ public void openManagementMenu(Player player, String spawnerId, String worldName, int listPage) { - SpawnerData spawner = spawnerManager.getSpawnerById(spawnerId); - if (spawner == null) { - messageService.sendMessage(player, "spawner_not_found"); - return; + openManagementMenu(player, spawnerId, worldName, listPage, null); + } + + /** + * Open management menu with optional remote server context. + */ + public void openManagementMenu(Player player, String spawnerId, String worldName, int listPage, String targetServer) { + boolean isRemote = targetServer != null && !targetServer.equals(getCurrentServerName()); + + // For local spawners, verify it exists + if (!isRemote) { + SpawnerData spawner = spawnerManager.getSpawnerById(spawnerId); + if (spawner == null) { + messageService.sendMessage(player, "spawner_not_found"); + return; + } } + String title = languageManager.getGuiTitle("spawner_management.title"); Inventory inv = Bukkit.createInventory( - new SpawnerManagementHolder(spawnerId, worldName, listPage), + new SpawnerManagementHolder(spawnerId, worldName, listPage, targetServer), INVENTORY_SIZE, title ); player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1.0f, 1.0f); - createActionItem(inv, TELEPORT_SLOT, "spawner_management.teleport", Material.ENDER_PEARL); - createActionItem(inv, OPEN_SPAWNER_SLOT, "spawner_management.open_spawner", Material.ENDER_EYE); - createActionItem(inv, STACK_SLOT, "spawner_management.stack", Material.SPAWNER); - createActionItem(inv, REMOVE_SLOT, "spawner_management.remove", Material.BARRIER); + + // Teleport button - disabled for remote servers (can't teleport cross-server) + if (isRemote) { + createDisabledTeleportItem(inv, TELEPORT_SLOT, targetServer); + } else { + createActionItem(inv, TELEPORT_SLOT, "spawner_management.teleport", Material.ENDER_PEARL); + } + + // Open spawner info button - enabled for both local and remote + // For remote: shows spawner info from database + // For local: opens the actual spawner menu + if (isRemote) { + createRemoteActionItem(inv, OPEN_SPAWNER_SLOT, "spawner_management.open_spawner", Material.ENDER_EYE, "View Info"); + } else { + createActionItem(inv, OPEN_SPAWNER_SLOT, "spawner_management.open_spawner", Material.ENDER_EYE); + } + + // Stack button - enabled for both local and remote + // For remote: updates stack size in database + if (isRemote) { + createRemoteActionItem(inv, STACK_SLOT, "spawner_management.stack", Material.SPAWNER, "Edit Stack Size"); + } else { + createActionItem(inv, STACK_SLOT, "spawner_management.stack", Material.SPAWNER); + } + + // Remove button - enabled for both local and remote + // For remote: removes from database (physical block remains until target server syncs) + if (isRemote) { + createRemoteActionItem(inv, REMOVE_SLOT, "spawner_management.remove", Material.BARRIER, "Remove from DB"); + } else { + createActionItem(inv, REMOVE_SLOT, "spawner_management.remove", Material.BARRIER); + } + createActionItem(inv, BACK_SLOT, "spawner_management.back", Material.RED_STAINED_GLASS_PANE); player.openInventory(inv); } @@ -71,4 +118,58 @@ private void createActionItem(Inventory inv, int slot, String langKey, Material if (item.getType() == Material.SPAWNER) VersionInitializer.hideTooltip(item); inv.setItem(slot, item); } -} \ No newline at end of file + + private void createDisabledTeleportItem(Inventory inv, int slot, String serverName) { + ItemStack item = new ItemStack(Material.BARRIER); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(ChatColor.RED + "Teleport Disabled"); + List lore = new ArrayList<>(); + lore.add(ChatColor.GRAY + "Must be on the same server"); + lore.add(ChatColor.GRAY + "to teleport to this spawner."); + lore.add(""); + lore.add(ChatColor.DARK_GRAY + "Spawner Server: " + ChatColor.WHITE + serverName); + meta.setLore(lore); + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS, ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_UNBREAKABLE); + item.setItemMeta(meta); + } + inv.setItem(slot, item); + } + + private void createDisabledActionItem(Inventory inv, int slot, String langKey, Material originalMaterial, String reason) { + ItemStack item = new ItemStack(Material.GRAY_STAINED_GLASS_PANE); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + String name = languageManager.getGuiItemName(langKey + ".name"); + meta.setDisplayName(ChatColor.GRAY + ChatColor.stripColor(name) + " (Disabled)"); + List lore = new ArrayList<>(); + lore.add(ChatColor.RED + "Not available for remote servers"); + meta.setLore(lore); + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS, ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_UNBREAKABLE); + item.setItemMeta(meta); + } + inv.setItem(slot, item); + } + + private void createRemoteActionItem(Inventory inv, int slot, String langKey, Material material, String action) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(languageManager.getGuiItemName(langKey + ".name")); + List lore = new ArrayList<>(Arrays.asList(languageManager.getGuiItemLore(langKey + ".lore"))); + lore.add(""); + lore.add(ChatColor.YELLOW + "Remote Server Action"); + lore.add(ChatColor.GRAY + "Changes are saved to database."); + lore.add(ChatColor.GRAY + "Target server will sync on next refresh."); + meta.setLore(lore); + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS, ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_UNBREAKABLE); + item.setItemMeta(meta); + } + if (item.getType() == Material.SPAWNER) VersionInitializer.hideTooltip(item); + inv.setItem(slot, item); + } + + private String getCurrentServerName() { + return plugin.getConfig().getString("database.server_name", "server1"); + } +} diff --git a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/management/SpawnerManagementHandler.java b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/management/SpawnerManagementHandler.java index 493dae0e..4dc1e18b 100644 --- a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/management/SpawnerManagementHandler.java +++ b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/management/SpawnerManagementHandler.java @@ -1,6 +1,7 @@ package github.nighter.smartspawner.commands.list.gui.management; import github.nighter.smartspawner.SmartSpawner; +import github.nighter.smartspawner.commands.list.gui.CrossServerSpawnerData; import github.nighter.smartspawner.commands.list.gui.adminstacker.AdminStackerUI; import github.nighter.smartspawner.commands.list.ListSubCommand; import github.nighter.smartspawner.commands.list.gui.list.enums.FilterOption; @@ -9,7 +10,8 @@ import github.nighter.smartspawner.spawner.gui.main.SpawnerMenuUI; import github.nighter.smartspawner.spawner.properties.SpawnerData; import github.nighter.smartspawner.spawner.data.SpawnerManager; -import github.nighter.smartspawner.spawner.data.SpawnerFileHandler; +import github.nighter.smartspawner.spawner.data.database.SpawnerDatabaseHandler; +import github.nighter.smartspawner.spawner.data.storage.SpawnerStorage; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Sound; @@ -19,6 +21,7 @@ import org.bukkit.event.inventory.InventoryClickEvent; import org.bukkit.inventory.ItemStack; +import java.util.Arrays; import java.util.HashMap; import java.util.Map; @@ -26,7 +29,7 @@ public class SpawnerManagementHandler implements Listener { private final SmartSpawner plugin; private final MessageService messageService; private final SpawnerManager spawnerManager; - private final SpawnerFileHandler spawnerFileHandler; + private final SpawnerStorage spawnerStorage; private final ListSubCommand listSubCommand; private final SpawnerMenuUI spawnerMenuUI; private final AdminStackerUI adminStackerUI; @@ -35,7 +38,7 @@ public SpawnerManagementHandler(SmartSpawner plugin, ListSubCommand listSubComma this.plugin = plugin; this.messageService = plugin.getMessageService(); this.spawnerManager = plugin.getSpawnerManager(); - this.spawnerFileHandler = plugin.getSpawnerFileHandler(); + this.spawnerStorage = plugin.getSpawnerStorage(); this.listSubCommand = listSubCommand; this.spawnerMenuUI = plugin.getSpawnerMenuUI(); this.adminStackerUI = new AdminStackerUI(plugin); @@ -52,22 +55,43 @@ public void onSpawnerManagementClick(InventoryClickEvent event) { String spawnerId = holder.getSpawnerId(); String worldName = holder.getWorldName(); int listPage = holder.getListPage(); + String targetServer = holder.getTargetServer(); + boolean isRemote = holder.isRemoteServer(); + int slot = event.getSlot(); + + // Handle back button - works for both local and remote + if (slot == 26) { + handleBack(player, worldName, listPage, targetServer); + return; + } + + // For remote servers, handle specific actions + if (isRemote) { + switch (slot) { + case 10 -> { + // Teleport disabled for remote + player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f); + } + case 12 -> handleRemoteOpenSpawnerInfo(player, spawnerId, targetServer, worldName, listPage); + case 14 -> handleRemoteStackManagement(player, spawnerId, targetServer, worldName, listPage); + case 16 -> handleRemoteRemoveSpawner(player, spawnerId, targetServer, worldName, listPage); + } + return; + } + + // Local spawner actions SpawnerData spawner = spawnerManager.getSpawnerById(spawnerId); if (spawner == null) { messageService.sendMessage(player, "spawner_not_found"); return; } - int slot = event.getSlot(); - ItemStack clickedItem = event.getCurrentItem(); - switch (slot) { case 10 -> handleTeleport(player, spawner); case 12 -> handleOpenSpawner(player, spawner); case 14 -> handleStackManagement(player, spawner, worldName, listPage); case 16 -> handleRemoveSpawner(player, spawner, worldName, listPage); - case 26 -> handleBack(player, worldName, listPage); } } @@ -116,21 +140,21 @@ private void handleRemoveSpawner(Player player, SpawnerData spawner, String worl // Remove from manager and save spawnerManager.removeSpawner(spawnerId); - spawnerFileHandler.markSpawnerDeleted(spawnerId); + spawnerStorage.markSpawnerDeleted(spawnerId); Map placeholders = new HashMap<>(); placeholders.put("id", spawner.getSpawnerId()); messageService.sendMessage(player, "spawner_management.removed", placeholders); player.playSound(player.getLocation(), Sound.ENTITY_ITEM_BREAK, 1.0f, 1.0f); // Return to spawner list - handleBack(player, worldName, listPage); + handleBack(player, worldName, listPage, null); } - private void handleBack(Player player, String worldName, int listPage) { + private void handleBack(Player player, String worldName, int listPage, String targetServer) { // Get the user's current preferences for filter and sort FilterOption filter = FilterOption.ALL; // Default SortOption sort = SortOption.DEFAULT; // Default - + // Try to get saved preferences try { filter = listSubCommand.getUserFilter(player, worldName); @@ -139,15 +163,126 @@ private void handleBack(Player player, String worldName, int listPage) { // Use defaults if loading fails } - listSubCommand.openSpawnerListGUI(player, worldName, listPage, filter, sort); + // Check if going back to a remote server's spawner list + if (targetServer != null && !targetServer.equals(listSubCommand.getCurrentServerName())) { + listSubCommand.openSpawnerListGUIForServer(player, targetServer, worldName, listPage); + } else { + listSubCommand.openSpawnerListGUI(player, worldName, listPage, filter, sort); + } player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1.0f, 1.0f); } private boolean isBedrockPlayer(Player player) { - if (plugin.getIntegrationManager() == null || + if (plugin.getIntegrationManager() == null || plugin.getIntegrationManager().getFloodgateHook() == null) { return false; } return plugin.getIntegrationManager().getFloodgateHook().isBedrockPlayer(player); } + + // ===== Remote Spawner Handlers ===== + + private void handleRemoteOpenSpawnerInfo(Player player, String spawnerId, String targetServer, + String worldName, int listPage) { + // Fetch spawner data from database and display info + SpawnerDatabaseHandler dbHandler = getDbHandler(); + if (dbHandler == null) { + messageService.sendMessage(player, "database_error"); + return; + } + + player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1.0f, 1.0f); + + dbHandler.getRemoteSpawnerByIdAsync(targetServer, spawnerId, spawnerData -> { + if (spawnerData == null) { + messageService.sendMessage(player, "spawner_not_found"); + return; + } + + // Send spawner info as chat message since we can't open the actual spawner menu + player.sendMessage(""); + player.sendMessage("§6§l=== Remote Spawner Info ==="); + player.sendMessage("§7Server: §f" + spawnerData.getServerName()); + player.sendMessage("§7ID: §f#" + spawnerData.getSpawnerId()); + player.sendMessage("§7Type: §f" + formatEntityName(spawnerData.getEntityType().name())); + player.sendMessage("§7Location: §f" + spawnerData.getWorldName() + " (" + + spawnerData.getLocX() + ", " + spawnerData.getLocY() + ", " + spawnerData.getLocZ() + ")"); + player.sendMessage("§7Stack Size: §f" + spawnerData.getStackSize()); + player.sendMessage("§7Status: " + (spawnerData.isActive() ? "§aActive" : "§cInactive")); + player.sendMessage("§7Stored XP: §f" + spawnerData.getStoredExp()); + player.sendMessage("§7Total Items: §f" + spawnerData.getTotalItems()); + player.sendMessage("§6§l=========================="); + player.sendMessage(""); + }); + } + + private void handleRemoteStackManagement(Player player, String spawnerId, String targetServer, + String worldName, int listPage) { + if (!player.hasPermission("smartspawner.stack")) { + messageService.sendMessage(player, "no_permission"); + return; + } + + // Open the admin stacker UI for remote spawner + SpawnerDatabaseHandler dbHandler = getDbHandler(); + if (dbHandler == null) { + messageService.sendMessage(player, "database_error"); + return; + } + + player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1.0f, 1.0f); + + // Fetch current stack size and open editor + dbHandler.getRemoteSpawnerByIdAsync(targetServer, spawnerId, spawnerData -> { + if (spawnerData == null) { + messageService.sendMessage(player, "spawner_not_found"); + return; + } + + // Open remote admin stacker UI + adminStackerUI.openRemoteAdminStackerGui(player, spawnerData, targetServer, worldName, listPage); + }); + } + + private void handleRemoteRemoveSpawner(Player player, String spawnerId, String targetServer, + String worldName, int listPage) { + SpawnerDatabaseHandler dbHandler = getDbHandler(); + if (dbHandler == null) { + messageService.sendMessage(player, "database_error"); + return; + } + + // Delete from database + dbHandler.deleteRemoteSpawnerAsync(targetServer, spawnerId, success -> { + if (success) { + Map placeholders = new HashMap<>(); + placeholders.put("id", spawnerId); + messageService.sendMessage(player, "spawner_management.removed", placeholders); + player.playSound(player.getLocation(), Sound.ENTITY_ITEM_BREAK, 1.0f, 1.0f); + + // Note: The physical block on the remote server will remain until that server syncs + player.sendMessage("§e[Note] The spawner block on " + targetServer + " will be removed when that server syncs."); + } else { + messageService.sendMessage(player, "spawner_not_found"); + player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f); + } + + // Return to spawner list + handleBack(player, worldName, listPage, targetServer); + }); + } + + private SpawnerDatabaseHandler getDbHandler() { + if (spawnerStorage instanceof SpawnerDatabaseHandler) { + return (SpawnerDatabaseHandler) spawnerStorage; + } + return null; + } + + private String formatEntityName(String name) { + return Arrays.stream(name.toLowerCase().split("_")) + .map(word -> word.substring(0, 1).toUpperCase() + word.substring(1)) + .reduce((a, b) -> a + " " + b) + .orElse(name); + } } diff --git a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/management/SpawnerManagementHolder.java b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/management/SpawnerManagementHolder.java index 2431c8f6..2119a501 100644 --- a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/management/SpawnerManagementHolder.java +++ b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/management/SpawnerManagementHolder.java @@ -9,15 +9,28 @@ public class SpawnerManagementHolder implements InventoryHolder { private final String spawnerId; private final String worldName; private final int listPage; + private final String targetServer; public SpawnerManagementHolder(String spawnerId, String worldName, int listPage) { + this(spawnerId, worldName, listPage, null); + } + + public SpawnerManagementHolder(String spawnerId, String worldName, int listPage, String targetServer) { this.spawnerId = spawnerId; this.worldName = worldName; this.listPage = listPage; + this.targetServer = targetServer; + } + + /** + * Check if this spawner is on a remote server. + */ + public boolean isRemoteServer() { + return targetServer != null; } @Override public Inventory getInventory() { return null; } -} \ No newline at end of file +} diff --git a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/serverselection/ServerSelectionHandler.java b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/serverselection/ServerSelectionHandler.java new file mode 100644 index 00000000..719fba82 --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/serverselection/ServerSelectionHandler.java @@ -0,0 +1,49 @@ +package github.nighter.smartspawner.commands.list.gui.serverselection; + +import github.nighter.smartspawner.SmartSpawner; +import github.nighter.smartspawner.commands.list.ListSubCommand; +import org.bukkit.ChatColor; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +/** + * Handles click events in the server selection GUI. + */ +public class ServerSelectionHandler implements Listener { + private final SmartSpawner plugin; + private final ListSubCommand listSubCommand; + + public ServerSelectionHandler(SmartSpawner plugin, ListSubCommand listSubCommand) { + this.plugin = plugin; + this.listSubCommand = listSubCommand; + } + + @EventHandler + public void onServerSelectionClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder(false) instanceof ServerSelectionHolder)) return; + if (!(event.getWhoClicked() instanceof Player player)) return; + + event.setCancelled(true); + + ItemStack clickedItem = event.getCurrentItem(); + if (clickedItem == null || !clickedItem.hasItemMeta()) return; + + ItemMeta meta = clickedItem.getItemMeta(); + if (meta == null || !meta.hasDisplayName()) return; + + // Extract server name from display name (strip color codes) + String serverName = ChatColor.stripColor(meta.getDisplayName()); + + if (serverName == null || serverName.isEmpty()) return; + + player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1.0f, 1.0f); + + // Open world selection for the selected server + listSubCommand.openWorldSelectionGUIForServer(player, serverName); + } +} diff --git a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/serverselection/ServerSelectionHolder.java b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/serverselection/ServerSelectionHolder.java new file mode 100644 index 00000000..f18251d8 --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/serverselection/ServerSelectionHolder.java @@ -0,0 +1,16 @@ +package github.nighter.smartspawner.commands.list.gui.serverselection; + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; + +/** + * Inventory holder for the server selection GUI. + * Used when sync_across_servers is enabled to select which server's spawners to view. + */ +public class ServerSelectionHolder implements InventoryHolder { + + @Override + public Inventory getInventory() { + return null; + } +} diff --git a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/worldselection/WorldSelectionHolder.java b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/worldselection/WorldSelectionHolder.java index 91af3bb6..4635259f 100644 --- a/core/src/main/java/github/nighter/smartspawner/commands/list/gui/worldselection/WorldSelectionHolder.java +++ b/core/src/main/java/github/nighter/smartspawner/commands/list/gui/worldselection/WorldSelectionHolder.java @@ -3,7 +3,44 @@ import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryHolder; +/** + * Inventory holder for the world selection GUI. + * Optionally stores a target server name for cross-server viewing. + */ public class WorldSelectionHolder implements InventoryHolder { + private final String targetServer; + + /** + * Create a world selection holder for local server. + */ + public WorldSelectionHolder() { + this.targetServer = null; + } + + /** + * Create a world selection holder for a specific server. + * @param targetServer The server name to view worlds from + */ + public WorldSelectionHolder(String targetServer) { + this.targetServer = targetServer; + } + + /** + * Get the target server name. + * @return The server name, or null if viewing local server + */ + public String getTargetServer() { + return targetServer; + } + + /** + * Check if this is viewing a remote server. + * @return true if viewing a remote server + */ + public boolean isRemoteServer() { + return targetServer != null; + } + @Override public Inventory getInventory() { return null; diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/data/SpawnerFileHandler.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/SpawnerFileHandler.java index 6ae68937..3b885f2e 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/data/SpawnerFileHandler.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/SpawnerFileHandler.java @@ -1,6 +1,7 @@ package github.nighter.smartspawner.spawner.data; import github.nighter.smartspawner.SmartSpawner; +import github.nighter.smartspawner.spawner.data.storage.SpawnerStorage; import github.nighter.smartspawner.spawner.properties.SpawnerData; import github.nighter.smartspawner.spawner.properties.VirtualInventory; import github.nighter.smartspawner.Scheduler; @@ -21,7 +22,7 @@ import java.util.logging.Logger; import java.util.stream.Collectors; -public class SpawnerFileHandler { +public class SpawnerFileHandler implements SpawnerStorage { private final SmartSpawner plugin; private final Logger logger; private File spawnerDataFile; @@ -44,6 +45,13 @@ public SpawnerFileHandler(SmartSpawner plugin) { startSaveTask(); } + @Override + public boolean initialize() { + // Initialization already happens in constructor + // This method exists for interface compliance + return spawnerDataFile != null && spawnerDataFile.exists(); + } + private void setupSpawnerDataFile() { spawnerDataFile = new File(plugin.getDataFolder(), "spawners_data.yml"); if (!spawnerDataFile.exists()) { @@ -74,6 +82,7 @@ private void startSaveTask() { }, intervalTicks, intervalTicks); } + @Override public void markSpawnerModified(String spawnerId) { if (spawnerId != null) { dirtySpawners.add(spawnerId); @@ -81,6 +90,7 @@ public void markSpawnerModified(String spawnerId) { } } + @Override public void markSpawnerDeleted(String spawnerId) { if (spawnerId != null) { deletedSpawners.add(spawnerId); @@ -88,6 +98,7 @@ public void markSpawnerDeleted(String spawnerId) { } } + @Override public void flushChanges() { if (dirtySpawners.isEmpty() && deletedSpawners.isEmpty()) { plugin.debug("No changes to flush"); @@ -233,6 +244,7 @@ private boolean saveSpawnerBatch(Map spawners) { } } + @Override public Map loadAllSpawnersRaw() { Map loadedSpawners = new HashMap<>(); @@ -255,6 +267,7 @@ public Map loadAllSpawnersRaw() { return loadedSpawners; } + @Override public SpawnerData loadSpecificSpawner(String spawnerId) { try { return loadSpawnerFromConfig(spawnerId, false); @@ -267,6 +280,7 @@ public SpawnerData loadSpecificSpawner(String spawnerId) { /** * Get the raw location string for a spawner (used by WorldEventHandler) */ + @Override public String getRawLocationString(String spawnerId) { String path = "spawners." + spawnerId + ".location"; return spawnerData.getString(path); @@ -479,10 +493,12 @@ private SpawnerData loadSpawnerFromConfig(String spawnerId, boolean logErrors, b return spawner; } + @Override public void queueSpawnerForSaving(String spawnerId) { markSpawnerModified(spawnerId); } + @Override public void shutdown() { if (saveTask != null) { saveTask.cancel(); diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/data/SpawnerManager.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/SpawnerManager.java index f17cffc1..7df80bb9 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/data/SpawnerManager.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/SpawnerManager.java @@ -2,6 +2,7 @@ import github.nighter.smartspawner.SmartSpawner; import github.nighter.smartspawner.Scheduler; +import github.nighter.smartspawner.spawner.data.storage.SpawnerStorage; import github.nighter.smartspawner.spawner.properties.SpawnerData; import org.bukkit.*; import java.util.*; @@ -12,13 +13,13 @@ public class SpawnerManager { private final Map spawners = new ConcurrentHashMap<>(); private final Map locationIndex = new HashMap<>(); private final Map> worldIndex = new HashMap<>(); - private final SpawnerFileHandler spawnerFileHandler; + private final SpawnerStorage spawnerStorage; // Set to keep track of confirmed ghost spawners to avoid repeated checks private final Set confirmedGhostSpawners = ConcurrentHashMap.newKeySet(); public SpawnerManager(SmartSpawner plugin) { this.plugin = plugin; - this.spawnerFileHandler = plugin.getSpawnerFileHandler(); + this.spawnerStorage = plugin.getSpawnerStorage(); // Initialize without loading spawners - let WorldEventHandler manage loading initializeWithoutLoading(); } @@ -85,7 +86,7 @@ public void addSpawner(String id, SpawnerData spawner) { worldIndex.computeIfAbsent(worldName, k -> new HashSet<>()).add(spawner); // Queue for saving - spawnerFileHandler.queueSpawnerForSaving(id); + spawnerStorage.queueSpawnerForSaving(id); } public void removeSpawner(String id) { @@ -196,7 +197,7 @@ public void removeGhostSpawner(String spawnerId) { Scheduler.runTask(() -> { removeSpawner(spawnerId); - spawnerFileHandler.markSpawnerDeleted(spawnerId); + spawnerStorage.markSpawnerDeleted(spawnerId); plugin.debug("Removed ghost spawner " + spawnerId); }); }); @@ -209,11 +210,11 @@ public void removeGhostSpawner(String spawnerId) { * @param spawnerId The ID of the modified spawner */ public void markSpawnerModified(String spawnerId) { - spawnerFileHandler.markSpawnerModified(spawnerId); + spawnerStorage.markSpawnerModified(spawnerId); } public void markSpawnerDeleted(String spawnerId) { - spawnerFileHandler.markSpawnerDeleted(spawnerId); + spawnerStorage.markSpawnerDeleted(spawnerId); } /** @@ -222,7 +223,7 @@ public void markSpawnerDeleted(String spawnerId) { * @param spawnerId The ID of the spawner to save */ public void queueSpawnerForSaving(String spawnerId) { - spawnerFileHandler.queueSpawnerForSaving(spawnerId); + spawnerStorage.queueSpawnerForSaving(spawnerId); } // =============================================================== diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/data/WorldEventHandler.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/WorldEventHandler.java index d65f54ae..b3ec8dd5 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/data/WorldEventHandler.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/WorldEventHandler.java @@ -81,7 +81,7 @@ public void onWorldSave(WorldSaveEvent event) { plugin.debug("World saving: " + world.getName()); // Flush any pending spawner changes for this world - plugin.getSpawnerFileHandler().flushChanges(); + plugin.getSpawnerStorage().flushChanges(); } /** @@ -101,7 +101,7 @@ public void onWorldUnload(WorldUnloadEvent event) { unloadSpawnersFromWorld(worldName); // Save any pending changes before unloading - plugin.getSpawnerFileHandler().flushChanges(); + plugin.getSpawnerStorage().flushChanges(); } /** @@ -116,7 +116,7 @@ public void attemptInitialSpawnerLoad() { plugin.debug("Attempting initial spawner load..."); // Load spawner data from file - Map allSpawnerData = plugin.getSpawnerFileHandler().loadAllSpawnersRaw(); + Map allSpawnerData = plugin.getSpawnerStorage().loadAllSpawnersRaw(); int loadedCount = 0; int pendingCount = 0; @@ -166,7 +166,7 @@ private void loadPendingSpawnersForWorld(String worldName) { if (pending != null && worldName.equals(pending.worldName)) { // Try to load this spawner now that its world is available - SpawnerData spawner = plugin.getSpawnerFileHandler().loadSpecificSpawner(spawnerId); + SpawnerData spawner = plugin.getSpawnerStorage().loadSpecificSpawner(spawnerId); if (spawner != null) { plugin.getSpawnerManager().addSpawnerToIndexes(spawnerId, spawner); @@ -211,7 +211,7 @@ private void unloadSpawnersFromWorld(String worldName) { */ private PendingSpawnerData loadPendingSpawnerFromFile(String spawnerId) { try { - String locationString = plugin.getSpawnerFileHandler().getRawLocationString(spawnerId); + String locationString = plugin.getSpawnerStorage().getRawLocationString(spawnerId); if (locationString != null) { String[] locParts = locationString.split(","); if (locParts.length >= 1) { diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/data/database/DatabaseManager.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/DatabaseManager.java new file mode 100644 index 00000000..792ab7ad --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/DatabaseManager.java @@ -0,0 +1,326 @@ +package github.nighter.smartspawner.spawner.data.database; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import github.nighter.smartspawner.SmartSpawner; +import github.nighter.smartspawner.spawner.data.storage.StorageMode; + +import java.io.File; +import java.sql.Connection; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Manages database connections using HikariCP connection pool. + * Supports MySQL/MariaDB and SQLite for spawner data storage. + */ +public class DatabaseManager { + private final SmartSpawner plugin; + private final Logger logger; + private final StorageMode storageMode; + private HikariDataSource dataSource; + + // Configuration values + private final String host; + private final int port; + private final String database; + private final String username; + private final String password; + private final String serverName; + private final String sqliteFile; + + // Pool settings + private final int maxPoolSize; + private final int minIdle; + private final long connectionTimeout; + private final long maxLifetime; + private final long idleTimeout; + private final long keepaliveTime; + private final long leakDetectionThreshold; + + // MySQL/MariaDB table creation SQL + private static final String CREATE_TABLE_MYSQL = """ + CREATE TABLE IF NOT EXISTS smart_spawners ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + spawner_id VARCHAR(64) NOT NULL, + server_name VARCHAR(64) NOT NULL, + + -- Location (separate columns for indexing) + world_name VARCHAR(128) NOT NULL, + loc_x INT NOT NULL, + loc_y INT NOT NULL, + loc_z INT NOT NULL, + + -- Entity data + entity_type VARCHAR(64) NOT NULL, + item_spawner_material VARCHAR(64) DEFAULT NULL, + + -- Settings + spawner_exp INT NOT NULL DEFAULT 0, + spawner_active BOOLEAN NOT NULL DEFAULT TRUE, + spawner_range INT NOT NULL DEFAULT 16, + spawner_stop BOOLEAN NOT NULL DEFAULT TRUE, + spawn_delay BIGINT NOT NULL DEFAULT 500, + max_spawner_loot_slots INT NOT NULL DEFAULT 45, + max_stored_exp INT NOT NULL DEFAULT 1000, + min_mobs INT NOT NULL DEFAULT 1, + max_mobs INT NOT NULL DEFAULT 4, + stack_size INT NOT NULL DEFAULT 1, + max_stack_size INT NOT NULL DEFAULT 1000, + last_spawn_time BIGINT NOT NULL DEFAULT 0, + is_at_capacity BOOLEAN NOT NULL DEFAULT FALSE, + + -- Player interaction + last_interacted_player VARCHAR(64) DEFAULT NULL, + preferred_sort_item VARCHAR(64) DEFAULT NULL, + filtered_items TEXT DEFAULT NULL, + + -- Inventory (JSON blob) + inventory_data MEDIUMTEXT DEFAULT NULL, + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- Indexes + UNIQUE KEY uk_server_spawner (server_name, spawner_id), + UNIQUE KEY uk_location (server_name, world_name, loc_x, loc_y, loc_z), + INDEX idx_server (server_name), + INDEX idx_world (server_name, world_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """; + + // SQLite table creation SQL (slightly different syntax) + private static final String CREATE_TABLE_SQLITE = """ + CREATE TABLE IF NOT EXISTS smart_spawners ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + spawner_id VARCHAR(64) NOT NULL, + server_name VARCHAR(64) NOT NULL, + + -- Location (separate columns for indexing) + world_name VARCHAR(128) NOT NULL, + loc_x INT NOT NULL, + loc_y INT NOT NULL, + loc_z INT NOT NULL, + + -- Entity data + entity_type VARCHAR(64) NOT NULL, + item_spawner_material VARCHAR(64) DEFAULT NULL, + + -- Settings + spawner_exp INT NOT NULL DEFAULT 0, + spawner_active BOOLEAN NOT NULL DEFAULT 1, + spawner_range INT NOT NULL DEFAULT 16, + spawner_stop BOOLEAN NOT NULL DEFAULT 1, + spawn_delay BIGINT NOT NULL DEFAULT 500, + max_spawner_loot_slots INT NOT NULL DEFAULT 45, + max_stored_exp INT NOT NULL DEFAULT 1000, + min_mobs INT NOT NULL DEFAULT 1, + max_mobs INT NOT NULL DEFAULT 4, + stack_size INT NOT NULL DEFAULT 1, + max_stack_size INT NOT NULL DEFAULT 1000, + last_spawn_time BIGINT NOT NULL DEFAULT 0, + is_at_capacity BOOLEAN NOT NULL DEFAULT 0, + + -- Player interaction + last_interacted_player VARCHAR(64) DEFAULT NULL, + preferred_sort_item VARCHAR(64) DEFAULT NULL, + filtered_items TEXT DEFAULT NULL, + + -- Inventory (JSON blob) + inventory_data TEXT DEFAULT NULL, + + -- Timestamps + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Unique constraints + UNIQUE (server_name, spawner_id), + UNIQUE (server_name, world_name, loc_x, loc_y, loc_z) + ) + """; + + // SQLite index creation (separate statements) + private static final String CREATE_INDEX_SERVER_SQLITE = + "CREATE INDEX IF NOT EXISTS idx_server ON smart_spawners (server_name)"; + private static final String CREATE_INDEX_WORLD_SQLITE = + "CREATE INDEX IF NOT EXISTS idx_world ON smart_spawners (server_name, world_name)"; + + public DatabaseManager(SmartSpawner plugin, StorageMode storageMode) { + this.plugin = plugin; + this.logger = plugin.getLogger(); + this.storageMode = storageMode; + + // Load configuration + this.host = plugin.getConfig().getString("database.sql.host", "localhost"); + this.port = plugin.getConfig().getInt("database.sql.port", 3306); + this.database = plugin.getConfig().getString("database.database", "smartspawner"); + this.username = plugin.getConfig().getString("database.sql.username", "root"); + this.password = plugin.getConfig().getString("database.sql.password", ""); + this.serverName = plugin.getConfig().getString("database.server_name", "server1"); + this.sqliteFile = plugin.getConfig().getString("database.sqlite.file", "spawners.db"); + + // Pool settings + this.maxPoolSize = plugin.getConfig().getInt("database.sql.pool.maximum-size", 10); + this.minIdle = plugin.getConfig().getInt("database.sql.pool.minimum-idle", 2); + this.connectionTimeout = plugin.getConfig().getLong("database.sql.pool.connection-timeout", 10000); + this.maxLifetime = plugin.getConfig().getLong("database.sql.pool.max-lifetime", 1800000); + this.idleTimeout = plugin.getConfig().getLong("database.sql.pool.idle-timeout", 600000); + this.keepaliveTime = plugin.getConfig().getLong("database.sql.pool.keepalive-time", 30000); + this.leakDetectionThreshold = plugin.getConfig().getLong("database.sql.pool.leak-detection-threshold", 0); + } + + /** + * Initialize the database connection pool and create tables. + * @return true if initialization was successful + */ + public boolean initialize() { + try { + setupDataSource(); + createTables(); + logger.info("Database connection pool initialized successfully."); + return true; + } catch (Exception e) { + logger.log(Level.SEVERE, "Failed to initialize database connection pool", e); + return false; + } + } + + private void setupDataSource() { + HikariConfig config = new HikariConfig(); + + if (storageMode == StorageMode.SQLITE) { + setupSQLiteDataSource(config); + } else { + setupMySQLDataSource(config); + } + + dataSource = new HikariDataSource(config); + } + + private void setupMySQLDataSource(HikariConfig config) { + // JDBC URL for MariaDB/MySQL + String jdbcUrl = String.format("jdbc:mariadb://%s:%d/%s?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC", + host, port, database); + + config.setJdbcUrl(jdbcUrl); + config.setDriverClassName("github.nighter.smartspawner.libs.mariadb.Driver"); + config.setUsername(username); + config.setPassword(password); + + // Pool settings + config.setMaximumPoolSize(maxPoolSize); + config.setMinimumIdle(minIdle); + config.setConnectionTimeout(connectionTimeout); + config.setMaxLifetime(maxLifetime); + config.setIdleTimeout(idleTimeout); + config.setKeepaliveTime(keepaliveTime); + config.setLeakDetectionThreshold(leakDetectionThreshold); + + // Performance settings for MySQL/MariaDB + config.setPoolName("SmartSpawner-HikariCP"); + config.addDataSourceProperty("cachePrepStmts", "true"); + config.addDataSourceProperty("prepStmtCacheSize", "250"); + config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + config.addDataSourceProperty("useServerPrepStmts", "true"); + config.addDataSourceProperty("useLocalSessionState", "true"); + config.addDataSourceProperty("rewriteBatchedStatements", "true"); + config.addDataSourceProperty("cacheResultSetMetadata", "true"); + config.addDataSourceProperty("cacheServerConfiguration", "true"); + config.addDataSourceProperty("elideSetAutoCommits", "true"); + config.addDataSourceProperty("maintainTimeStats", "false"); + } + + private void setupSQLiteDataSource(HikariConfig config) { + // Create data folder if it doesn't exist + File dataFolder = plugin.getDataFolder(); + if (!dataFolder.exists()) { + dataFolder.mkdirs(); + } + + // JDBC URL for SQLite (file-based) + File dbFile = new File(dataFolder, sqliteFile); + String jdbcUrl = "jdbc:sqlite:" + dbFile.getAbsolutePath(); + + config.setJdbcUrl(jdbcUrl); + config.setDriverClassName("org.sqlite.JDBC"); + + // SQLite-specific pool settings (SQLite doesn't handle multiple connections well) + config.setMaximumPoolSize(1); // SQLite works best with single connection + config.setMinimumIdle(1); + config.setConnectionTimeout(connectionTimeout); + config.setMaxLifetime(0); // Disable max lifetime for SQLite + config.setIdleTimeout(0); // Disable idle timeout for SQLite + + // SQLite performance settings + config.setPoolName("SmartSpawner-SQLite-HikariCP"); + config.addDataSourceProperty("journal_mode", "WAL"); + config.addDataSourceProperty("synchronous", "NORMAL"); + config.addDataSourceProperty("cache_size", "10000"); + config.addDataSourceProperty("foreign_keys", "ON"); + } + + private void createTables() throws SQLException { + try (Connection conn = getConnection(); + Statement stmt = conn.createStatement()) { + + if (storageMode == StorageMode.SQLITE) { + stmt.execute(CREATE_TABLE_SQLITE); + stmt.execute(CREATE_INDEX_SERVER_SQLITE); + stmt.execute(CREATE_INDEX_WORLD_SQLITE); + } else { + stmt.execute(CREATE_TABLE_MYSQL); + } + + plugin.debug("Database tables created/verified successfully."); + } + } + + /** + * Get a connection from the pool. + * @return A database connection + * @throws SQLException if connection cannot be obtained + */ + public Connection getConnection() throws SQLException { + if (dataSource == null || dataSource.isClosed()) { + throw new SQLException("Database connection pool is not initialized or has been closed"); + } + return dataSource.getConnection(); + } + + /** + * Get the configured server name for this server. + * @return The server name used to identify spawners + */ + public String getServerName() { + return serverName; + } + + /** + * Get the storage mode this manager is configured for. + * @return The storage mode (MYSQL or SQLITE) + */ + public StorageMode getStorageMode() { + return storageMode; + } + + /** + * Check if the database connection pool is active. + * @return true if the pool is active and accepting connections + */ + public boolean isActive() { + return dataSource != null && !dataSource.isClosed(); + } + + /** + * Shutdown the database connection pool. + */ + public void shutdown() { + if (dataSource != null && !dataSource.isClosed()) { + dataSource.close(); + logger.info("Database connection pool closed."); + } + } +} diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/data/database/SpawnerDatabaseHandler.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/SpawnerDatabaseHandler.java new file mode 100644 index 00000000..02bc76c1 --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/SpawnerDatabaseHandler.java @@ -0,0 +1,1131 @@ +package github.nighter.smartspawner.spawner.data.database; + +import github.nighter.smartspawner.SmartSpawner; +import github.nighter.smartspawner.Scheduler; +import github.nighter.smartspawner.commands.list.gui.CrossServerSpawnerData; +import github.nighter.smartspawner.spawner.data.storage.SpawnerStorage; +import github.nighter.smartspawner.spawner.data.storage.StorageMode; +import github.nighter.smartspawner.spawner.properties.SpawnerData; +import github.nighter.smartspawner.spawner.properties.VirtualInventory; +import github.nighter.smartspawner.spawner.utils.ItemStackSerializer; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.EntityType; +import org.bukkit.inventory.ItemStack; + +import java.sql.*; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * Database-backed storage handler for spawner data. + * Implements SpawnerStorage interface with MariaDB operations. + */ +public class SpawnerDatabaseHandler implements SpawnerStorage { + private final SmartSpawner plugin; + private final Logger logger; + private final DatabaseManager databaseManager; + private final String serverName; + + // Dirty tracking for batch saves + private final Set dirtySpawners = ConcurrentHashMap.newKeySet(); + private final Set deletedSpawners = ConcurrentHashMap.newKeySet(); + + private volatile boolean isSaving = false; + private Scheduler.Task saveTask = null; + + // Cache for raw location strings (used by WorldEventHandler) + private final Map locationCache = new ConcurrentHashMap<>(); + + // SQL Statements + private static final String SELECT_ALL_SQL = """ + SELECT spawner_id, world_name, loc_x, loc_y, loc_z, entity_type, item_spawner_material, + spawner_exp, spawner_active, spawner_range, spawner_stop, spawn_delay, + max_spawner_loot_slots, max_stored_exp, min_mobs, max_mobs, stack_size, + max_stack_size, last_spawn_time, is_at_capacity, last_interacted_player, + preferred_sort_item, filtered_items, inventory_data + FROM smart_spawners WHERE server_name = ? + """; + + private static final String SELECT_ONE_SQL = """ + SELECT spawner_id, world_name, loc_x, loc_y, loc_z, entity_type, item_spawner_material, + spawner_exp, spawner_active, spawner_range, spawner_stop, spawn_delay, + max_spawner_loot_slots, max_stored_exp, min_mobs, max_mobs, stack_size, + max_stack_size, last_spawn_time, is_at_capacity, last_interacted_player, + preferred_sort_item, filtered_items, inventory_data + FROM smart_spawners WHERE server_name = ? AND spawner_id = ? + """; + + private static final String SELECT_LOCATION_SQL = """ + SELECT world_name, loc_x, loc_y, loc_z FROM smart_spawners + WHERE server_name = ? AND spawner_id = ? + """; + + // MySQL/MariaDB upsert syntax + private static final String UPSERT_SQL_MYSQL = """ + INSERT INTO smart_spawners ( + spawner_id, server_name, world_name, loc_x, loc_y, loc_z, + entity_type, item_spawner_material, spawner_exp, spawner_active, + spawner_range, spawner_stop, spawn_delay, max_spawner_loot_slots, + max_stored_exp, min_mobs, max_mobs, stack_size, max_stack_size, + last_spawn_time, is_at_capacity, last_interacted_player, + preferred_sort_item, filtered_items, inventory_data + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + world_name = VALUES(world_name), + loc_x = VALUES(loc_x), + loc_y = VALUES(loc_y), + loc_z = VALUES(loc_z), + entity_type = VALUES(entity_type), + item_spawner_material = VALUES(item_spawner_material), + spawner_exp = VALUES(spawner_exp), + spawner_active = VALUES(spawner_active), + spawner_range = VALUES(spawner_range), + spawner_stop = VALUES(spawner_stop), + spawn_delay = VALUES(spawn_delay), + max_spawner_loot_slots = VALUES(max_spawner_loot_slots), + max_stored_exp = VALUES(max_stored_exp), + min_mobs = VALUES(min_mobs), + max_mobs = VALUES(max_mobs), + stack_size = VALUES(stack_size), + max_stack_size = VALUES(max_stack_size), + last_spawn_time = VALUES(last_spawn_time), + is_at_capacity = VALUES(is_at_capacity), + last_interacted_player = VALUES(last_interacted_player), + preferred_sort_item = VALUES(preferred_sort_item), + filtered_items = VALUES(filtered_items), + inventory_data = VALUES(inventory_data) + """; + + // SQLite upsert syntax (ON CONFLICT) + private static final String UPSERT_SQL_SQLITE = """ + INSERT INTO smart_spawners ( + spawner_id, server_name, world_name, loc_x, loc_y, loc_z, + entity_type, item_spawner_material, spawner_exp, spawner_active, + spawner_range, spawner_stop, spawn_delay, max_spawner_loot_slots, + max_stored_exp, min_mobs, max_mobs, stack_size, max_stack_size, + last_spawn_time, is_at_capacity, last_interacted_player, + preferred_sort_item, filtered_items, inventory_data + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(server_name, spawner_id) DO UPDATE SET + world_name = excluded.world_name, + loc_x = excluded.loc_x, + loc_y = excluded.loc_y, + loc_z = excluded.loc_z, + entity_type = excluded.entity_type, + item_spawner_material = excluded.item_spawner_material, + spawner_exp = excluded.spawner_exp, + spawner_active = excluded.spawner_active, + spawner_range = excluded.spawner_range, + spawner_stop = excluded.spawner_stop, + spawn_delay = excluded.spawn_delay, + max_spawner_loot_slots = excluded.max_spawner_loot_slots, + max_stored_exp = excluded.max_stored_exp, + min_mobs = excluded.min_mobs, + max_mobs = excluded.max_mobs, + stack_size = excluded.stack_size, + max_stack_size = excluded.max_stack_size, + last_spawn_time = excluded.last_spawn_time, + is_at_capacity = excluded.is_at_capacity, + last_interacted_player = excluded.last_interacted_player, + preferred_sort_item = excluded.preferred_sort_item, + filtered_items = excluded.filtered_items, + inventory_data = excluded.inventory_data + """; + + private static final String DELETE_SQL = """ + DELETE FROM smart_spawners WHERE server_name = ? AND spawner_id = ? + """; + + public SpawnerDatabaseHandler(SmartSpawner plugin, DatabaseManager databaseManager) { + this.plugin = plugin; + this.logger = plugin.getLogger(); + this.databaseManager = databaseManager; + this.serverName = databaseManager.getServerName(); + } + + @Override + public boolean initialize() { + if (!databaseManager.isActive()) { + logger.severe("Database manager is not active, cannot initialize SpawnerDatabaseHandler"); + return false; + } + + // Start the periodic save task + startSaveTask(); + return true; + } + + private void startSaveTask() { + // Hardcoded 5-minute interval (5 * 60 * 20 = 6000 ticks) + long intervalTicks = 6000L; + + if (saveTask != null) { + saveTask.cancel(); + saveTask = null; + } + + saveTask = Scheduler.runTaskTimerAsync(() -> { + plugin.debug("Running scheduled database save task"); + flushChanges(); + }, intervalTicks, intervalTicks); + } + + @Override + public void markSpawnerModified(String spawnerId) { + if (spawnerId != null) { + dirtySpawners.add(spawnerId); + deletedSpawners.remove(spawnerId); + } + } + + @Override + public void markSpawnerDeleted(String spawnerId) { + if (spawnerId != null) { + deletedSpawners.add(spawnerId); + dirtySpawners.remove(spawnerId); + locationCache.remove(spawnerId); + } + } + + @Override + public void queueSpawnerForSaving(String spawnerId) { + markSpawnerModified(spawnerId); + } + + @Override + public void flushChanges() { + if (dirtySpawners.isEmpty() && deletedSpawners.isEmpty()) { + plugin.debug("No database changes to flush"); + return; + } + + if (isSaving) { + plugin.debug("Database flush operation already in progress"); + return; + } + + isSaving = true; + plugin.debug("Flushing " + dirtySpawners.size() + " modified and " + deletedSpawners.size() + " deleted spawners to database"); + + Scheduler.runTaskAsync(() -> { + try { + // Handle updates + if (!dirtySpawners.isEmpty()) { + Set toUpdate = new HashSet<>(dirtySpawners); + dirtySpawners.removeAll(toUpdate); + + saveSpawnerBatch(toUpdate); + } + + // Handle deletes + if (!deletedSpawners.isEmpty()) { + Set toDelete = new HashSet<>(deletedSpawners); + deletedSpawners.removeAll(toDelete); + + deleteSpawnerBatch(toDelete); + } + } catch (Exception e) { + logger.log(Level.SEVERE, "Error during database flush", e); + // Re-add failed items back to dirty lists + // Note: In production, might want more sophisticated retry logic + } finally { + isSaving = false; + } + }); + } + + private void saveSpawnerBatch(Set spawnerIds) { + if (spawnerIds.isEmpty()) return; + + // Select appropriate SQL based on storage mode + String upsertSql = databaseManager.getStorageMode() == StorageMode.SQLITE + ? UPSERT_SQL_SQLITE + : UPSERT_SQL_MYSQL; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(upsertSql)) { + + conn.setAutoCommit(false); + + for (String spawnerId : spawnerIds) { + SpawnerData spawner = plugin.getSpawnerManager().getSpawnerById(spawnerId); + if (spawner == null) continue; + + setSpawnerParameters(stmt, spawner); + stmt.addBatch(); + } + + stmt.executeBatch(); + conn.commit(); + plugin.debug("Saved " + spawnerIds.size() + " spawners to database"); + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error saving spawner batch to database", e); + // Re-add to dirty list for retry + dirtySpawners.addAll(spawnerIds); + } + } + + private void deleteSpawnerBatch(Set spawnerIds) { + if (spawnerIds.isEmpty()) return; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(DELETE_SQL)) { + + conn.setAutoCommit(false); + + for (String spawnerId : spawnerIds) { + stmt.setString(1, serverName); + stmt.setString(2, spawnerId); + stmt.addBatch(); + } + + stmt.executeBatch(); + conn.commit(); + plugin.debug("Deleted " + spawnerIds.size() + " spawners from database"); + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error deleting spawner batch from database", e); + // Re-add to deleted list for retry + deletedSpawners.addAll(spawnerIds); + } + } + + private void setSpawnerParameters(PreparedStatement stmt, SpawnerData spawner) throws SQLException { + Location loc = spawner.getSpawnerLocation(); + + stmt.setString(1, spawner.getSpawnerId()); + stmt.setString(2, serverName); + stmt.setString(3, loc.getWorld().getName()); + stmt.setInt(4, loc.getBlockX()); + stmt.setInt(5, loc.getBlockY()); + stmt.setInt(6, loc.getBlockZ()); + stmt.setString(7, spawner.getEntityType().name()); + stmt.setString(8, spawner.isItemSpawner() ? spawner.getSpawnedItemMaterial().name() : null); + stmt.setInt(9, spawner.getSpawnerExp()); + stmt.setBoolean(10, spawner.getSpawnerActive()); + stmt.setInt(11, spawner.getSpawnerRange()); + stmt.setBoolean(12, spawner.getSpawnerStop().get()); + stmt.setLong(13, spawner.getSpawnDelay()); + stmt.setInt(14, spawner.getMaxSpawnerLootSlots()); + stmt.setInt(15, spawner.getMaxStoredExp()); + stmt.setInt(16, spawner.getMinMobs()); + stmt.setInt(17, spawner.getMaxMobs()); + stmt.setInt(18, spawner.getStackSize()); + stmt.setInt(19, spawner.getMaxStackSize()); + stmt.setLong(20, spawner.getLastSpawnTime()); + stmt.setBoolean(21, spawner.getIsAtCapacity()); + stmt.setString(22, spawner.getLastInteractedPlayer()); + stmt.setString(23, spawner.getPreferredSortItem() != null ? spawner.getPreferredSortItem().name() : null); + stmt.setString(24, serializeFilteredItems(spawner.getFilteredItems())); + stmt.setString(25, serializeInventory(spawner.getVirtualInventory())); + } + + @Override + public Map loadAllSpawnersRaw() { + Map loadedSpawners = new HashMap<>(); + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(SELECT_ALL_SQL)) { + + stmt.setString(1, serverName); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String spawnerId = rs.getString("spawner_id"); + try { + SpawnerData spawner = loadSpawnerFromResultSet(rs); + loadedSpawners.put(spawnerId, spawner); + + // Cache location for WorldEventHandler + if (spawner == null) { + String worldName = rs.getString("world_name"); + int x = rs.getInt("loc_x"); + int y = rs.getInt("loc_y"); + int z = rs.getInt("loc_z"); + locationCache.put(spawnerId, String.format("%s,%d,%d,%d", worldName, x, y, z)); + } + } catch (Exception e) { + plugin.debug("Error loading spawner " + spawnerId + ": " + e.getMessage()); + loadedSpawners.put(spawnerId, null); + } + } + } + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error loading spawners from database", e); + } + + return loadedSpawners; + } + + @Override + public SpawnerData loadSpecificSpawner(String spawnerId) { + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(SELECT_ONE_SQL)) { + + stmt.setString(1, serverName); + stmt.setString(2, spawnerId); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return loadSpawnerFromResultSet(rs); + } + } + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error loading spawner " + spawnerId + " from database", e); + } + + return null; + } + + @Override + public String getRawLocationString(String spawnerId) { + // Check cache first + String cached = locationCache.get(spawnerId); + if (cached != null) { + return cached; + } + + // Query database + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(SELECT_LOCATION_SQL)) { + + stmt.setString(1, serverName); + stmt.setString(2, spawnerId); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + String worldName = rs.getString("world_name"); + int x = rs.getInt("loc_x"); + int y = rs.getInt("loc_y"); + int z = rs.getInt("loc_z"); + String location = String.format("%s,%d,%d,%d", worldName, x, y, z); + locationCache.put(spawnerId, location); + return location; + } + } + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error getting location for spawner " + spawnerId, e); + } + + return null; + } + + private SpawnerData loadSpawnerFromResultSet(ResultSet rs) throws SQLException { + String spawnerId = rs.getString("spawner_id"); + String worldName = rs.getString("world_name"); + int x = rs.getInt("loc_x"); + int y = rs.getInt("loc_y"); + int z = rs.getInt("loc_z"); + + org.bukkit.World world = Bukkit.getWorld(worldName); + if (world == null) { + plugin.debug("World not yet loaded for spawner " + spawnerId + ": " + worldName); + return null; + } + + Location location = new Location(world, x, y, z); + String entityTypeStr = rs.getString("entity_type"); + EntityType entityType; + try { + entityType = EntityType.valueOf(entityTypeStr); + } catch (IllegalArgumentException e) { + logger.severe("Invalid entity type for spawner " + spawnerId + ": " + entityTypeStr); + return null; + } + + // Create spawner based on type + SpawnerData spawner; + String itemMaterialStr = rs.getString("item_spawner_material"); + if (entityType == EntityType.ITEM && itemMaterialStr != null) { + try { + Material itemMaterial = Material.valueOf(itemMaterialStr); + spawner = new SpawnerData(spawnerId, location, itemMaterial, plugin); + } catch (IllegalArgumentException e) { + logger.severe("Invalid item spawner material for spawner " + spawnerId + ": " + itemMaterialStr); + return null; + } + } else { + spawner = new SpawnerData(spawnerId, location, entityType, plugin); + } + + // Load settings + spawner.setSpawnerExpData(rs.getInt("spawner_exp")); + spawner.setSpawnerActive(rs.getBoolean("spawner_active")); + spawner.setSpawnerRange(rs.getInt("spawner_range")); + spawner.getSpawnerStop().set(rs.getBoolean("spawner_stop")); + spawner.setSpawnDelayFromConfig(); // Use config delay + spawner.setMaxSpawnerLootSlots(rs.getInt("max_spawner_loot_slots")); + spawner.setMaxStoredExp(rs.getInt("max_stored_exp")); + spawner.setMinMobs(rs.getInt("min_mobs")); + spawner.setMaxMobs(rs.getInt("max_mobs")); + spawner.setStackSize(rs.getInt("stack_size"), false); // Don't restart hopper during batch load + spawner.setMaxStackSize(rs.getInt("max_stack_size")); + spawner.setLastSpawnTime(rs.getLong("last_spawn_time")); + spawner.setIsAtCapacity(rs.getBoolean("is_at_capacity")); + + // Load player interaction data + spawner.setLastInteractedPlayer(rs.getString("last_interacted_player")); + + // Load preferred sort item + String preferredSortItemStr = rs.getString("preferred_sort_item"); + if (preferredSortItemStr != null && !preferredSortItemStr.isEmpty()) { + try { + Material preferredSortItem = Material.valueOf(preferredSortItemStr); + spawner.setPreferredSortItem(preferredSortItem); + } catch (IllegalArgumentException e) { + logger.warning("Invalid preferred sort item for spawner " + spawnerId + ": " + preferredSortItemStr); + } + } + + // Load filtered items + String filteredItemsStr = rs.getString("filtered_items"); + if (filteredItemsStr != null && !filteredItemsStr.isEmpty()) { + deserializeFilteredItems(filteredItemsStr, spawner.getFilteredItems()); + } + + // Load inventory + String inventoryData = rs.getString("inventory_data"); + VirtualInventory virtualInv = new VirtualInventory(spawner.getMaxSpawnerLootSlots()); + if (inventoryData != null && !inventoryData.isEmpty()) { + try { + loadInventoryFromJson(inventoryData, virtualInv); + } catch (Exception e) { + logger.warning("Error loading inventory for spawner " + spawnerId + ": " + e.getMessage()); + } + } + spawner.setVirtualInventory(virtualInv); + + // Recalculate accumulated sell value after loading inventory + spawner.recalculateSellValue(); + + // Apply sort preference to virtual inventory + if (spawner.getPreferredSortItem() != null) { + virtualInv.sortItems(spawner.getPreferredSortItem()); + } + + // Restore the physical spawner block state for item spawners + if (spawner.isItemSpawner()) { + Scheduler.runLocationTask(location, () -> { + org.bukkit.block.Block block = location.getBlock(); + if (block.getType() == Material.SPAWNER) { + org.bukkit.block.BlockState state = block.getState(false); + if (state instanceof org.bukkit.block.CreatureSpawner cs) { + cs.setSpawnedType(EntityType.ITEM); + ItemStack spawnedItem = new ItemStack(spawner.getSpawnedItemMaterial(), 1); + cs.setSpawnedItem(spawnedItem); + cs.update(true, false); + } + } + }); + } + + return spawner; + } + + @Override + public void shutdown() { + if (saveTask != null) { + saveTask.cancel(); + saveTask = null; + } + + // Perform synchronous flush on shutdown + if (!dirtySpawners.isEmpty() || !deletedSpawners.isEmpty()) { + try { + isSaving = true; + logger.info("Saving " + dirtySpawners.size() + " spawners to database on shutdown..."); + + if (!dirtySpawners.isEmpty()) { + saveSpawnerBatch(new HashSet<>(dirtySpawners)); + } + + if (!deletedSpawners.isEmpty()) { + deleteSpawnerBatch(new HashSet<>(deletedSpawners)); + } + + dirtySpawners.clear(); + deletedSpawners.clear(); + logger.info("Database shutdown save completed."); + + } catch (Exception e) { + logger.log(Level.SEVERE, "Error during database shutdown flush", e); + } finally { + isSaving = false; + } + } + + locationCache.clear(); + } + + // ============== Serialization Helpers ============== + + private String serializeFilteredItems(Set filteredItems) { + if (filteredItems == null || filteredItems.isEmpty()) { + return null; + } + return filteredItems.stream() + .map(Material::name) + .collect(Collectors.joining(",")); + } + + private void deserializeFilteredItems(String data, Set filteredItems) { + if (data == null || data.isEmpty()) return; + + String[] materialNames = data.split(","); + for (String materialName : materialNames) { + try { + Material material = Material.valueOf(materialName.trim()); + filteredItems.add(material); + } catch (IllegalArgumentException e) { + logger.warning("Invalid material in filtered items: " + materialName); + } + } + } + + private String serializeInventory(VirtualInventory virtualInv) { + if (virtualInv == null) { + return null; + } + + Map items = virtualInv.getConsolidatedItems(); + if (items.isEmpty()) { + return null; + } + + // Use existing ItemStackSerializer format, then join with a delimiter + List serializedItems = ItemStackSerializer.serializeInventory(items); + if (serializedItems.isEmpty()) { + return null; + } + + // Use a JSON-like array format that's easy to parse + // Format: ["item1:count","item2;damage:count:count",...] + StringBuilder sb = new StringBuilder(); + sb.append("["); + for (int i = 0; i < serializedItems.size(); i++) { + if (i > 0) sb.append(","); + // Escape any quotes in the string and wrap in quotes + sb.append("\"").append(serializedItems.get(i).replace("\"", "\\\"")).append("\""); + } + sb.append("]"); + return sb.toString(); + } + + private void loadInventoryFromJson(String jsonData, VirtualInventory virtualInv) { + if (jsonData == null || jsonData.isEmpty()) return; + + // Parse our simple JSON array format + // Format: ["item1:count","item2;damage:count:count",...] + if (!jsonData.startsWith("[") || !jsonData.endsWith("]")) { + logger.warning("Invalid inventory JSON format: " + jsonData); + return; + } + + String content = jsonData.substring(1, jsonData.length() - 1); + if (content.isEmpty()) return; + + List items = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean inQuotes = false; + boolean escaped = false; + + for (char c : content.toCharArray()) { + if (escaped) { + current.append(c); + escaped = false; + continue; + } + + if (c == '\\') { + escaped = true; + continue; + } + + if (c == '"') { + inQuotes = !inQuotes; + continue; + } + + if (c == ',' && !inQuotes) { + if (current.length() > 0) { + items.add(current.toString()); + current = new StringBuilder(); + } + continue; + } + + current.append(c); + } + + if (current.length() > 0) { + items.add(current.toString()); + } + + if (items.isEmpty()) return; + + // Use existing ItemStackSerializer to deserialize + try { + Map deserializedItems = ItemStackSerializer.deserializeInventory(items); + for (Map.Entry entry : deserializedItems.entrySet()) { + ItemStack item = entry.getKey(); + int amount = entry.getValue(); + + if (item != null && amount > 0) { + while (amount > 0) { + int batchSize = Math.min(amount, item.getMaxStackSize()); + ItemStack batch = item.clone(); + batch.setAmount(batchSize); + virtualInv.addItems(Collections.singletonList(batch)); + amount -= batchSize; + } + } + } + } catch (Exception e) { + logger.warning("Error deserializing inventory data: " + e.getMessage()); + } + } + + // ============== Cross-Server Query Methods ============== + + /** + * Get the current server name. + * @return The server name from config + */ + public String getServerName() { + return serverName; + } + + /** + * Asynchronously get all distinct server names from the database. + * @param callback Consumer to receive the list of server names on the main thread + */ + public void getDistinctServerNamesAsync(Consumer> callback) { + Scheduler.runTaskAsync(() -> { + List servers = new ArrayList<>(); + String sql = "SELECT DISTINCT server_name FROM smart_spawners ORDER BY server_name"; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + + while (rs.next()) { + servers.add(rs.getString("server_name")); + } + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error fetching server names from database", e); + } + + // Return to main thread + Scheduler.runTask(() -> callback.accept(servers)); + }); + } + + /** + * Asynchronously get world names with spawner counts for a specific server. + * @param targetServer The server name to query + * @param callback Consumer to receive map of world name -> spawner count + */ + public void getWorldsForServerAsync(String targetServer, Consumer> callback) { + Scheduler.runTaskAsync(() -> { + Map worlds = new LinkedHashMap<>(); + String sql = "SELECT world_name, COUNT(*) as count FROM smart_spawners WHERE server_name = ? GROUP BY world_name ORDER BY world_name"; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, targetServer); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + worlds.put(rs.getString("world_name"), rs.getInt("count")); + } + } + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error fetching worlds for server " + targetServer, e); + } + + Scheduler.runTask(() -> callback.accept(worlds)); + }); + } + + /** + * Asynchronously get total stacked spawner count for a server/world. + * @param targetServer The server name + * @param worldName The world name + * @param callback Consumer to receive total stack count + */ + public void getTotalStacksForWorldAsync(String targetServer, String worldName, Consumer callback) { + Scheduler.runTaskAsync(() -> { + int total = 0; + String sql = "SELECT SUM(stack_size) as total FROM smart_spawners WHERE server_name = ? AND world_name = ?"; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, targetServer); + stmt.setString(2, worldName); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + total = rs.getInt("total"); + } + } + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error fetching stack total for " + targetServer + "/" + worldName, e); + } + + final int finalTotal = total; + Scheduler.runTask(() -> callback.accept(finalTotal)); + }); + } + + /** + * Asynchronously get spawner data for a specific server and world. + * Returns CrossServerSpawnerData objects that don't require Bukkit Location objects. + * @param targetServer The server name to query + * @param worldName The world name to query + * @param callback Consumer to receive list of spawner data + */ + public void getCrossServerSpawnersAsync(String targetServer, String worldName, Consumer> callback) { + Scheduler.runTaskAsync(() -> { + List spawners = new ArrayList<>(); + String sql = """ + SELECT spawner_id, server_name, world_name, loc_x, loc_y, loc_z, + entity_type, stack_size, spawner_stop, last_interacted_player, + spawner_exp, inventory_data + FROM smart_spawners + WHERE server_name = ? AND world_name = ? + ORDER BY stack_size DESC + """; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, targetServer); + stmt.setString(2, worldName); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String spawnerId = rs.getString("spawner_id"); + String server = rs.getString("server_name"); + String world = rs.getString("world_name"); + int x = rs.getInt("loc_x"); + int y = rs.getInt("loc_y"); + int z = rs.getInt("loc_z"); + + EntityType entityType; + try { + entityType = EntityType.valueOf(rs.getString("entity_type")); + } catch (IllegalArgumentException e) { + entityType = EntityType.PIG; // Fallback + } + + int stackSize = rs.getInt("stack_size"); + boolean active = !rs.getBoolean("spawner_stop"); + String lastPlayer = rs.getString("last_interacted_player"); + int storedExp = rs.getInt("spawner_exp"); + + // Estimate total items from inventory data + long totalItems = estimateItemCount(rs.getString("inventory_data")); + + spawners.add(new CrossServerSpawnerData( + spawnerId, server, world, x, y, z, + entityType, stackSize, active, lastPlayer, + storedExp, totalItems + )); + } + } + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error fetching spawners for " + targetServer + "/" + worldName, e); + } + + Scheduler.runTask(() -> callback.accept(spawners)); + }); + } + + /** + * Get spawner count for a specific server. + * @param targetServer The server name + * @param callback Consumer to receive the count + */ + public void getSpawnerCountForServerAsync(String targetServer, Consumer callback) { + Scheduler.runTaskAsync(() -> { + int count = 0; + String sql = "SELECT COUNT(*) as count FROM smart_spawners WHERE server_name = ?"; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, targetServer); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + count = rs.getInt("count"); + } + } + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error fetching spawner count for " + targetServer, e); + } + + final int finalCount = count; + Scheduler.runTask(() -> callback.accept(finalCount)); + }); + } + + /** + * Asynchronously get spawner data for a specific server and world with filter and sort. + * @param targetServer The server name to query + * @param worldName The world name to query + * @param filter Filter option (ALL, ACTIVE, INACTIVE) + * @param sort Sort option (DEFAULT, STACK_SIZE_DESC, STACK_SIZE_ASC) + * @param callback Consumer to receive list of spawner data + */ + public void getCrossServerSpawnersAsync(String targetServer, String worldName, + String filter, String sort, + Consumer> callback) { + Scheduler.runTaskAsync(() -> { + List spawners = new ArrayList<>(); + + // Build dynamic SQL based on filter and sort + StringBuilder sql = new StringBuilder(""" + SELECT spawner_id, server_name, world_name, loc_x, loc_y, loc_z, + entity_type, stack_size, spawner_stop, last_interacted_player, + spawner_exp, inventory_data + FROM smart_spawners + WHERE server_name = ? AND world_name = ? + """); + + // Add filter condition + if ("ACTIVE".equalsIgnoreCase(filter)) { + sql.append(" AND spawner_stop = FALSE"); + } else if ("INACTIVE".equalsIgnoreCase(filter)) { + sql.append(" AND spawner_stop = TRUE"); + } + + // Add sort order + if ("STACK_SIZE_ASC".equalsIgnoreCase(sort)) { + sql.append(" ORDER BY stack_size ASC"); + } else if ("STACK_SIZE_DESC".equalsIgnoreCase(sort)) { + sql.append(" ORDER BY stack_size DESC"); + } else { + sql.append(" ORDER BY spawner_id ASC"); // DEFAULT sort + } + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql.toString())) { + + stmt.setString(1, targetServer); + stmt.setString(2, worldName); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String spawnerId = rs.getString("spawner_id"); + String server = rs.getString("server_name"); + String world = rs.getString("world_name"); + int x = rs.getInt("loc_x"); + int y = rs.getInt("loc_y"); + int z = rs.getInt("loc_z"); + + EntityType entityType; + try { + entityType = EntityType.valueOf(rs.getString("entity_type")); + } catch (IllegalArgumentException e) { + entityType = EntityType.PIG; // Fallback + } + + int stackSize = rs.getInt("stack_size"); + boolean active = !rs.getBoolean("spawner_stop"); + String lastPlayer = rs.getString("last_interacted_player"); + int storedExp = rs.getInt("spawner_exp"); + long totalItems = estimateItemCount(rs.getString("inventory_data")); + + spawners.add(new CrossServerSpawnerData( + spawnerId, server, world, x, y, z, + entityType, stackSize, active, lastPlayer, + storedExp, totalItems + )); + } + } + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error fetching spawners for " + targetServer + "/" + worldName, e); + } + + Scheduler.runTask(() -> callback.accept(spawners)); + }); + } + + /** + * Asynchronously get a single spawner's data from a remote server. + * @param targetServer The server name + * @param spawnerId The spawner ID + * @param callback Consumer to receive the spawner data (null if not found) + */ + public void getRemoteSpawnerByIdAsync(String targetServer, String spawnerId, + Consumer callback) { + Scheduler.runTaskAsync(() -> { + CrossServerSpawnerData spawnerData = null; + String sql = """ + SELECT spawner_id, server_name, world_name, loc_x, loc_y, loc_z, + entity_type, stack_size, spawner_stop, last_interacted_player, + spawner_exp, inventory_data + FROM smart_spawners + WHERE server_name = ? AND spawner_id = ? + """; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, targetServer); + stmt.setString(2, spawnerId); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + String world = rs.getString("world_name"); + int x = rs.getInt("loc_x"); + int y = rs.getInt("loc_y"); + int z = rs.getInt("loc_z"); + + EntityType entityType; + try { + entityType = EntityType.valueOf(rs.getString("entity_type")); + } catch (IllegalArgumentException e) { + entityType = EntityType.PIG; + } + + int stackSize = rs.getInt("stack_size"); + boolean active = !rs.getBoolean("spawner_stop"); + String lastPlayer = rs.getString("last_interacted_player"); + int storedExp = rs.getInt("spawner_exp"); + long totalItems = estimateItemCount(rs.getString("inventory_data")); + + spawnerData = new CrossServerSpawnerData( + spawnerId, targetServer, world, x, y, z, + entityType, stackSize, active, lastPlayer, + storedExp, totalItems + ); + } + } + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error fetching remote spawner " + spawnerId + " from " + targetServer, e); + } + + final CrossServerSpawnerData result = spawnerData; + Scheduler.runTask(() -> callback.accept(result)); + }); + } + + /** + * Asynchronously update stack size for a remote spawner. + * @param targetServer The server name + * @param spawnerId The spawner ID + * @param newStackSize The new stack size + * @param callback Consumer to receive success status + */ + public void updateRemoteSpawnerStackSizeAsync(String targetServer, String spawnerId, + int newStackSize, Consumer callback) { + Scheduler.runTaskAsync(() -> { + boolean success = false; + String sql = "UPDATE smart_spawners SET stack_size = ?, updated_at = CURRENT_TIMESTAMP WHERE server_name = ? AND spawner_id = ?"; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setInt(1, newStackSize); + stmt.setString(2, targetServer); + stmt.setString(3, spawnerId); + + int affected = stmt.executeUpdate(); + success = affected > 0; + + if (success) { + plugin.debug("Updated remote spawner " + spawnerId + " on " + targetServer + " to stack size " + newStackSize); + } + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error updating remote spawner stack size", e); + } + + final boolean result = success; + Scheduler.runTask(() -> callback.accept(result)); + }); + } + + /** + * Asynchronously delete a remote spawner from the database. + * Note: This only removes the database record. The physical block on the target server + * will remain until that server refreshes its cache. + * @param targetServer The server name + * @param spawnerId The spawner ID + * @param callback Consumer to receive success status + */ + public void deleteRemoteSpawnerAsync(String targetServer, String spawnerId, + Consumer callback) { + Scheduler.runTaskAsync(() -> { + boolean success = false; + String sql = "DELETE FROM smart_spawners WHERE server_name = ? AND spawner_id = ?"; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, targetServer); + stmt.setString(2, spawnerId); + + int affected = stmt.executeUpdate(); + success = affected > 0; + + if (success) { + logger.info("Deleted remote spawner " + spawnerId + " from " + targetServer + " database record"); + } + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Error deleting remote spawner", e); + } + + final boolean result = success; + Scheduler.runTask(() -> callback.accept(result)); + }); + } + + /** + * Estimate total item count from inventory JSON data. + */ + private long estimateItemCount(String inventoryData) { + if (inventoryData == null || inventoryData.isEmpty()) { + return 0; + } + + long total = 0; + // Simple regex to find numbers after colons (item counts) + // Format: ["ITEM:count","ITEM:count",...] + try { + String[] parts = inventoryData.split(":"); + for (int i = 1; i < parts.length; i++) { + String numPart = parts[i].replaceAll("[^0-9]", " ").trim().split(" ")[0]; + if (!numPart.isEmpty()) { + total += Long.parseLong(numPart); + } + } + } catch (Exception e) { + // Ignore parsing errors + } + return total; + } +} diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/data/database/SqliteToMySqlMigration.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/SqliteToMySqlMigration.java new file mode 100644 index 00000000..2c2938da --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/SqliteToMySqlMigration.java @@ -0,0 +1,229 @@ +package github.nighter.smartspawner.spawner.data.database; + +import github.nighter.smartspawner.SmartSpawner; +import github.nighter.smartspawner.spawner.data.storage.StorageMode; + +import java.io.File; +import java.sql.*; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Handles one-time migration from SQLite database to MySQL/MariaDB. + * After successful migration, the SQLite file is renamed to spawners.db.migrated + * to prevent re-migration. + */ +public class SqliteToMySqlMigration { + private final SmartSpawner plugin; + private final Logger logger; + private final DatabaseManager mysqlManager; + private final String serverName; + + private static final String MIGRATED_FILE_SUFFIX = ".migrated"; + + // MySQL insert syntax (target) + private static final String INSERT_SQL_MYSQL = """ + INSERT INTO smart_spawners ( + spawner_id, server_name, world_name, loc_x, loc_y, loc_z, + entity_type, item_spawner_material, spawner_exp, spawner_active, + spawner_range, spawner_stop, spawn_delay, max_spawner_loot_slots, + max_stored_exp, min_mobs, max_mobs, stack_size, max_stack_size, + last_spawn_time, is_at_capacity, last_interacted_player, + preferred_sort_item, filtered_items, inventory_data + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + world_name = VALUES(world_name), + loc_x = VALUES(loc_x), + loc_y = VALUES(loc_y), + loc_z = VALUES(loc_z), + entity_type = VALUES(entity_type), + item_spawner_material = VALUES(item_spawner_material), + spawner_exp = VALUES(spawner_exp), + spawner_active = VALUES(spawner_active), + spawner_range = VALUES(spawner_range), + spawner_stop = VALUES(spawner_stop), + spawn_delay = VALUES(spawn_delay), + max_spawner_loot_slots = VALUES(max_spawner_loot_slots), + max_stored_exp = VALUES(max_stored_exp), + min_mobs = VALUES(min_mobs), + max_mobs = VALUES(max_mobs), + stack_size = VALUES(stack_size), + max_stack_size = VALUES(max_stack_size), + last_spawn_time = VALUES(last_spawn_time), + is_at_capacity = VALUES(is_at_capacity), + last_interacted_player = VALUES(last_interacted_player), + preferred_sort_item = VALUES(preferred_sort_item), + filtered_items = VALUES(filtered_items), + inventory_data = VALUES(inventory_data) + """; + + private static final String SELECT_ALL_SQLITE = """ + SELECT spawner_id, server_name, world_name, loc_x, loc_y, loc_z, + entity_type, item_spawner_material, spawner_exp, spawner_active, + spawner_range, spawner_stop, spawn_delay, max_spawner_loot_slots, + max_stored_exp, min_mobs, max_mobs, stack_size, max_stack_size, + last_spawn_time, is_at_capacity, last_interacted_player, + preferred_sort_item, filtered_items, inventory_data + FROM smart_spawners + """; + + public SqliteToMySqlMigration(SmartSpawner plugin, DatabaseManager mysqlManager) { + this.plugin = plugin; + this.logger = plugin.getLogger(); + this.mysqlManager = mysqlManager; + this.serverName = mysqlManager.getServerName(); + } + + /** + * Check if migration is needed. + * Migration is needed if SQLite database file exists and hasn't been migrated. + * @return true if migration is needed + */ + public boolean needsMigration() { + // Only migrate when target is MySQL + if (mysqlManager.getStorageMode() != StorageMode.MYSQL) { + return false; + } + + String sqliteFileName = plugin.getConfig().getString("database.sqlite.file", "spawners.db"); + File sqliteFile = new File(plugin.getDataFolder(), sqliteFileName); + + if (!sqliteFile.exists()) { + return false; + } + + // Check if already migrated + File migratedFile = new File(plugin.getDataFolder(), sqliteFileName + MIGRATED_FILE_SUFFIX); + if (migratedFile.exists()) { + return false; + } + + // Check if SQLite has any data + return hasSqliteData(sqliteFile); + } + + private boolean hasSqliteData(File sqliteFile) { + String jdbcUrl = "jdbc:sqlite:" + sqliteFile.getAbsolutePath(); + + try (Connection conn = DriverManager.getConnection(jdbcUrl); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT COUNT(*) FROM smart_spawners")) { + + if (rs.next()) { + return rs.getInt(1) > 0; + } + } catch (SQLException e) { + // Table might not exist or other error + plugin.debug("SQLite check failed: " + e.getMessage()); + } + + return false; + } + + /** + * Perform the migration from SQLite to MySQL. + * @return true if migration was successful + */ + public boolean migrate() { + logger.info("Starting SQLite to MySQL migration..."); + + String sqliteFileName = plugin.getConfig().getString("database.sqlite.file", "spawners.db"); + File sqliteFile = new File(plugin.getDataFolder(), sqliteFileName); + + if (!sqliteFile.exists()) { + logger.info("No SQLite file found, skipping migration."); + return true; + } + + String sqliteJdbcUrl = "jdbc:sqlite:" + sqliteFile.getAbsolutePath(); + + int totalSpawners = 0; + int migratedCount = 0; + int failedCount = 0; + + try (Connection sqliteConn = DriverManager.getConnection(sqliteJdbcUrl); + Connection mysqlConn = mysqlManager.getConnection(); + PreparedStatement selectStmt = sqliteConn.prepareStatement(SELECT_ALL_SQLITE); + PreparedStatement insertStmt = mysqlConn.prepareStatement(INSERT_SQL_MYSQL)) { + + mysqlConn.setAutoCommit(false); + + try (ResultSet rs = selectStmt.executeQuery()) { + int batchCount = 0; + final int BATCH_SIZE = 100; + + while (rs.next()) { + totalSpawners++; + + try { + // Transfer all columns + insertStmt.setString(1, rs.getString("spawner_id")); + insertStmt.setString(2, rs.getString("server_name")); + insertStmt.setString(3, rs.getString("world_name")); + insertStmt.setInt(4, rs.getInt("loc_x")); + insertStmt.setInt(5, rs.getInt("loc_y")); + insertStmt.setInt(6, rs.getInt("loc_z")); + insertStmt.setString(7, rs.getString("entity_type")); + insertStmt.setString(8, rs.getString("item_spawner_material")); + insertStmt.setInt(9, rs.getInt("spawner_exp")); + insertStmt.setBoolean(10, rs.getBoolean("spawner_active")); + insertStmt.setInt(11, rs.getInt("spawner_range")); + insertStmt.setBoolean(12, rs.getBoolean("spawner_stop")); + insertStmt.setLong(13, rs.getLong("spawn_delay")); + insertStmt.setInt(14, rs.getInt("max_spawner_loot_slots")); + insertStmt.setInt(15, rs.getInt("max_stored_exp")); + insertStmt.setInt(16, rs.getInt("min_mobs")); + insertStmt.setInt(17, rs.getInt("max_mobs")); + insertStmt.setInt(18, rs.getInt("stack_size")); + insertStmt.setInt(19, rs.getInt("max_stack_size")); + insertStmt.setLong(20, rs.getLong("last_spawn_time")); + insertStmt.setBoolean(21, rs.getBoolean("is_at_capacity")); + insertStmt.setString(22, rs.getString("last_interacted_player")); + insertStmt.setString(23, rs.getString("preferred_sort_item")); + insertStmt.setString(24, rs.getString("filtered_items")); + insertStmt.setString(25, rs.getString("inventory_data")); + + insertStmt.addBatch(); + batchCount++; + migratedCount++; + + // Execute batch every BATCH_SIZE records + if (batchCount >= BATCH_SIZE) { + insertStmt.executeBatch(); + mysqlConn.commit(); + batchCount = 0; + logger.info("Migrated " + migratedCount + " spawners..."); + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to migrate spawner: " + rs.getString("spawner_id"), e); + failedCount++; + } + } + + // Execute remaining batch + if (batchCount > 0) { + insertStmt.executeBatch(); + mysqlConn.commit(); + } + } + + logger.info("Migration completed. Total: " + totalSpawners + ", Migrated: " + migratedCount + ", Failed: " + failedCount); + + // Rename the SQLite file to prevent re-migration + if (failedCount == 0 || migratedCount > 0) { + File migratedFile = new File(plugin.getDataFolder(), sqliteFileName + MIGRATED_FILE_SUFFIX); + if (sqliteFile.renameTo(migratedFile)) { + logger.info("SQLite file renamed to " + sqliteFileName + MIGRATED_FILE_SUFFIX); + } else { + logger.warning("Failed to rename SQLite file. Manual cleanup may be required."); + } + } + + return failedCount == 0; + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Database error during SQLite to MySQL migration", e); + return false; + } + } +} diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/data/database/YamlToDatabaseMigration.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/YamlToDatabaseMigration.java new file mode 100644 index 00000000..7d29c8bc --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/YamlToDatabaseMigration.java @@ -0,0 +1,386 @@ +package github.nighter.smartspawner.spawner.data.database; + +import github.nighter.smartspawner.SmartSpawner; +import github.nighter.smartspawner.spawner.data.storage.StorageMode; +import github.nighter.smartspawner.spawner.properties.SpawnerData; +import github.nighter.smartspawner.spawner.properties.VirtualInventory; +import github.nighter.smartspawner.spawner.utils.ItemStackSerializer; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.EntityType; +import org.bukkit.inventory.ItemStack; + +import java.io.File; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.*; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +/** + * Handles one-time migration from spawners_data.yml to database (MySQL or SQLite). + * After successful migration, the YAML file is renamed to spawners_data.yml.migrated + * to prevent re-migration. + */ +public class YamlToDatabaseMigration { + private final SmartSpawner plugin; + private final Logger logger; + private final DatabaseManager databaseManager; + private final String serverName; + + private static final String YAML_FILE_NAME = "spawners_data.yml"; + private static final String MIGRATED_FILE_SUFFIX = ".migrated"; + + // MySQL/MariaDB insert syntax + private static final String INSERT_SQL_MYSQL = """ + INSERT INTO smart_spawners ( + spawner_id, server_name, world_name, loc_x, loc_y, loc_z, + entity_type, item_spawner_material, spawner_exp, spawner_active, + spawner_range, spawner_stop, spawn_delay, max_spawner_loot_slots, + max_stored_exp, min_mobs, max_mobs, stack_size, max_stack_size, + last_spawn_time, is_at_capacity, last_interacted_player, + preferred_sort_item, filtered_items, inventory_data + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + world_name = VALUES(world_name), + loc_x = VALUES(loc_x), + loc_y = VALUES(loc_y), + loc_z = VALUES(loc_z), + entity_type = VALUES(entity_type), + item_spawner_material = VALUES(item_spawner_material), + spawner_exp = VALUES(spawner_exp), + spawner_active = VALUES(spawner_active), + spawner_range = VALUES(spawner_range), + spawner_stop = VALUES(spawner_stop), + spawn_delay = VALUES(spawn_delay), + max_spawner_loot_slots = VALUES(max_spawner_loot_slots), + max_stored_exp = VALUES(max_stored_exp), + min_mobs = VALUES(min_mobs), + max_mobs = VALUES(max_mobs), + stack_size = VALUES(stack_size), + max_stack_size = VALUES(max_stack_size), + last_spawn_time = VALUES(last_spawn_time), + is_at_capacity = VALUES(is_at_capacity), + last_interacted_player = VALUES(last_interacted_player), + preferred_sort_item = VALUES(preferred_sort_item), + filtered_items = VALUES(filtered_items), + inventory_data = VALUES(inventory_data) + """; + + // SQLite insert syntax + private static final String INSERT_SQL_SQLITE = """ + INSERT INTO smart_spawners ( + spawner_id, server_name, world_name, loc_x, loc_y, loc_z, + entity_type, item_spawner_material, spawner_exp, spawner_active, + spawner_range, spawner_stop, spawn_delay, max_spawner_loot_slots, + max_stored_exp, min_mobs, max_mobs, stack_size, max_stack_size, + last_spawn_time, is_at_capacity, last_interacted_player, + preferred_sort_item, filtered_items, inventory_data + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(server_name, spawner_id) DO UPDATE SET + world_name = excluded.world_name, + loc_x = excluded.loc_x, + loc_y = excluded.loc_y, + loc_z = excluded.loc_z, + entity_type = excluded.entity_type, + item_spawner_material = excluded.item_spawner_material, + spawner_exp = excluded.spawner_exp, + spawner_active = excluded.spawner_active, + spawner_range = excluded.spawner_range, + spawner_stop = excluded.spawner_stop, + spawn_delay = excluded.spawn_delay, + max_spawner_loot_slots = excluded.max_spawner_loot_slots, + max_stored_exp = excluded.max_stored_exp, + min_mobs = excluded.min_mobs, + max_mobs = excluded.max_mobs, + stack_size = excluded.stack_size, + max_stack_size = excluded.max_stack_size, + last_spawn_time = excluded.last_spawn_time, + is_at_capacity = excluded.is_at_capacity, + last_interacted_player = excluded.last_interacted_player, + preferred_sort_item = excluded.preferred_sort_item, + filtered_items = excluded.filtered_items, + inventory_data = excluded.inventory_data + """; + + public YamlToDatabaseMigration(SmartSpawner plugin, DatabaseManager databaseManager) { + this.plugin = plugin; + this.logger = plugin.getLogger(); + this.databaseManager = databaseManager; + this.serverName = databaseManager.getServerName(); + } + + /** + * Check if migration is needed. + * Migration is needed if spawners_data.yml exists and has spawner data. + * @return true if migration is needed + */ + public boolean needsMigration() { + File yamlFile = new File(plugin.getDataFolder(), YAML_FILE_NAME); + if (!yamlFile.exists()) { + return false; + } + + // Check if already migrated + File migratedFile = new File(plugin.getDataFolder(), YAML_FILE_NAME + MIGRATED_FILE_SUFFIX); + if (migratedFile.exists()) { + return false; + } + + // Check if YAML has any spawner data + FileConfiguration yamlData = YamlConfiguration.loadConfiguration(yamlFile); + ConfigurationSection spawnersSection = yamlData.getConfigurationSection("spawners"); + return spawnersSection != null && !spawnersSection.getKeys(false).isEmpty(); + } + + /** + * Perform the migration from YAML to database. + * @return true if migration was successful + */ + public boolean migrate() { + logger.info("Starting YAML to database migration..."); + + File yamlFile = new File(plugin.getDataFolder(), YAML_FILE_NAME); + if (!yamlFile.exists()) { + logger.info("No YAML file found, skipping migration."); + return true; + } + + FileConfiguration yamlData = YamlConfiguration.loadConfiguration(yamlFile); + ConfigurationSection spawnersSection = yamlData.getConfigurationSection("spawners"); + + if (spawnersSection == null || spawnersSection.getKeys(false).isEmpty()) { + logger.info("No spawners found in YAML file, skipping migration."); + return true; + } + + int totalSpawners = spawnersSection.getKeys(false).size(); + int migratedCount = 0; + int failedCount = 0; + + logger.info("Found " + totalSpawners + " spawners to migrate."); + + // Select appropriate SQL based on storage mode + String insertSql = databaseManager.getStorageMode() == StorageMode.SQLITE + ? INSERT_SQL_SQLITE + : INSERT_SQL_MYSQL; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(insertSql)) { + + conn.setAutoCommit(false); + int batchCount = 0; + final int BATCH_SIZE = 100; + + for (String spawnerId : spawnersSection.getKeys(false)) { + try { + if (migrateSpawner(stmt, yamlData, spawnerId)) { + stmt.addBatch(); + batchCount++; + migratedCount++; + + // Execute batch every BATCH_SIZE records + if (batchCount >= BATCH_SIZE) { + stmt.executeBatch(); + conn.commit(); + batchCount = 0; + logger.info("Migrated " + migratedCount + "/" + totalSpawners + " spawners..."); + } + } else { + failedCount++; + } + } catch (Exception e) { + logger.log(Level.WARNING, "Failed to migrate spawner " + spawnerId, e); + failedCount++; + } + } + + // Execute remaining batch + if (batchCount > 0) { + stmt.executeBatch(); + conn.commit(); + } + + logger.info("Migration completed. Migrated: " + migratedCount + ", Failed: " + failedCount); + + // Rename the YAML file to prevent re-migration + if (failedCount == 0 || migratedCount > 0) { + File migratedFile = new File(plugin.getDataFolder(), YAML_FILE_NAME + MIGRATED_FILE_SUFFIX); + if (yamlFile.renameTo(migratedFile)) { + logger.info("YAML file renamed to " + YAML_FILE_NAME + MIGRATED_FILE_SUFFIX); + } else { + logger.warning("Failed to rename YAML file. Manual cleanup may be required."); + } + } + + return failedCount == 0; + + } catch (SQLException e) { + logger.log(Level.SEVERE, "Database error during migration", e); + return false; + } + } + + private boolean migrateSpawner(PreparedStatement stmt, FileConfiguration yamlData, String spawnerId) throws SQLException { + String path = "spawners." + spawnerId; + + // Parse location + String locationString = yamlData.getString(path + ".location"); + if (locationString == null) { + logger.warning("No location for spawner " + spawnerId + ", skipping."); + return false; + } + + String[] locParts = locationString.split(","); + if (locParts.length != 4) { + logger.warning("Invalid location format for spawner " + spawnerId + ", skipping."); + return false; + } + + String worldName = locParts[0]; + int locX, locY, locZ; + try { + locX = Integer.parseInt(locParts[1]); + locY = Integer.parseInt(locParts[2]); + locZ = Integer.parseInt(locParts[3]); + } catch (NumberFormatException e) { + logger.warning("Invalid location coordinates for spawner " + spawnerId + ", skipping."); + return false; + } + + // Parse entity type + String entityTypeString = yamlData.getString(path + ".entityType"); + if (entityTypeString == null) { + logger.warning("No entity type for spawner " + spawnerId + ", skipping."); + return false; + } + + EntityType entityType; + try { + entityType = EntityType.valueOf(entityTypeString); + } catch (IllegalArgumentException e) { + logger.warning("Invalid entity type for spawner " + spawnerId + ": " + entityTypeString + ", skipping."); + return false; + } + + // Parse item spawner material (if applicable) + String itemSpawnerMaterial = yamlData.getString(path + ".itemSpawnerMaterial"); + + // Parse settings string + String settingsString = yamlData.getString(path + ".settings"); + int spawnerExp = 0; + boolean spawnerActive = true; + int spawnerRange = 16; + boolean spawnerStop = true; + long spawnDelay = 500; + int maxSpawnerLootSlots = 45; + int maxStoredExp = 1000; + int minMobs = 1; + int maxMobs = 4; + int stackSize = 1; + int maxStackSize = 1000; + long lastSpawnTime = 0; + boolean isAtCapacity = false; + + if (settingsString != null) { + String[] settings = settingsString.split(","); + int version = yamlData.getInt("data_version", 1); + + try { + if (version >= 3 && settings.length >= 13) { + spawnerExp = Integer.parseInt(settings[0]); + spawnerActive = Boolean.parseBoolean(settings[1]); + spawnerRange = Integer.parseInt(settings[2]); + spawnerStop = Boolean.parseBoolean(settings[3]); + spawnDelay = Long.parseLong(settings[4]); + maxSpawnerLootSlots = Integer.parseInt(settings[5]); + maxStoredExp = Integer.parseInt(settings[6]); + minMobs = Integer.parseInt(settings[7]); + maxMobs = Integer.parseInt(settings[8]); + stackSize = Integer.parseInt(settings[9]); + maxStackSize = Integer.parseInt(settings[10]); + lastSpawnTime = Long.parseLong(settings[11]); + isAtCapacity = Boolean.parseBoolean(settings[12]); + } else if (settings.length >= 11) { + spawnerExp = Integer.parseInt(settings[0]); + spawnerActive = Boolean.parseBoolean(settings[1]); + spawnerRange = Integer.parseInt(settings[2]); + spawnerStop = Boolean.parseBoolean(settings[3]); + spawnDelay = Long.parseLong(settings[4]); + maxSpawnerLootSlots = Integer.parseInt(settings[5]); + maxStoredExp = Integer.parseInt(settings[6]); + minMobs = Integer.parseInt(settings[7]); + maxMobs = Integer.parseInt(settings[8]); + stackSize = Integer.parseInt(settings[9]); + lastSpawnTime = Long.parseLong(settings[10]); + } + } catch (NumberFormatException e) { + logger.warning("Invalid settings format for spawner " + spawnerId + ", using defaults."); + } + } + + // Parse filtered items + String filteredItemsStr = yamlData.getString(path + ".filteredItems"); + + // Parse preferred sort item + String preferredSortItemStr = yamlData.getString(path + ".preferredSortItem"); + + // Parse last interacted player + String lastInteractedPlayer = yamlData.getString(path + ".lastInteractedPlayer"); + + // Parse inventory and convert to JSON format + List inventoryData = yamlData.getStringList(path + ".inventory"); + String inventoryJson = serializeInventoryToJson(inventoryData); + + // Set statement parameters + stmt.setString(1, spawnerId); + stmt.setString(2, serverName); + stmt.setString(3, worldName); + stmt.setInt(4, locX); + stmt.setInt(5, locY); + stmt.setInt(6, locZ); + stmt.setString(7, entityType.name()); + stmt.setString(8, itemSpawnerMaterial); + stmt.setInt(9, spawnerExp); + stmt.setBoolean(10, spawnerActive); + stmt.setInt(11, spawnerRange); + stmt.setBoolean(12, spawnerStop); + stmt.setLong(13, spawnDelay); + stmt.setInt(14, maxSpawnerLootSlots); + stmt.setInt(15, maxStoredExp); + stmt.setInt(16, minMobs); + stmt.setInt(17, maxMobs); + stmt.setInt(18, stackSize); + stmt.setInt(19, maxStackSize); + stmt.setLong(20, lastSpawnTime); + stmt.setBoolean(21, isAtCapacity); + stmt.setString(22, lastInteractedPlayer); + stmt.setString(23, preferredSortItemStr); + stmt.setString(24, filteredItemsStr); + stmt.setString(25, inventoryJson); + + return true; + } + + private String serializeInventoryToJson(List inventoryData) { + if (inventoryData == null || inventoryData.isEmpty()) { + return null; + } + + // Convert YAML list format to JSON array format + StringBuilder sb = new StringBuilder(); + sb.append("["); + for (int i = 0; i < inventoryData.size(); i++) { + if (i > 0) sb.append(","); + sb.append("\"").append(inventoryData.get(i).replace("\"", "\\\"")).append("\""); + } + sb.append("]"); + return sb.toString(); + } +} diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/data/storage/SpawnerStorage.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/storage/SpawnerStorage.java new file mode 100644 index 00000000..ca57e763 --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/storage/SpawnerStorage.java @@ -0,0 +1,71 @@ +package github.nighter.smartspawner.spawner.data.storage; + +import github.nighter.smartspawner.spawner.properties.SpawnerData; + +import java.util.Map; + +/** + * Interface defining storage operations for spawner data. + * Implementations can use YAML files or MariaDB database. + */ +public interface SpawnerStorage { + + /** + * Initialize the storage system. + * Called during plugin startup. + * @return true if initialization was successful + */ + boolean initialize(); + + /** + * Shutdown the storage system gracefully. + * Should flush all pending changes before returning. + */ + void shutdown(); + + /** + * Load all spawners from storage. + * Spawners whose worlds are not loaded will have null values. + * @return Map of spawner IDs to SpawnerData (null values for unloadable spawners) + */ + Map loadAllSpawnersRaw(); + + /** + * Load a specific spawner by ID. + * @param spawnerId The spawner ID to load + * @return The SpawnerData or null if not found or world not loaded + */ + SpawnerData loadSpecificSpawner(String spawnerId); + + /** + * Mark a spawner as modified for batch saving. + * @param spawnerId The ID of the modified spawner + */ + void markSpawnerModified(String spawnerId); + + /** + * Mark a spawner as deleted for batch removal. + * @param spawnerId The ID of the deleted spawner + */ + void markSpawnerDeleted(String spawnerId); + + /** + * Queue a spawner for saving (alias for markSpawnerModified). + * @param spawnerId The ID of the spawner to save + */ + void queueSpawnerForSaving(String spawnerId); + + /** + * Flush all pending changes to storage. + * Called periodically and before shutdown. + */ + void flushChanges(); + + /** + * Get the raw location string for a spawner. + * Used by WorldEventHandler for pending spawner loading. + * @param spawnerId The spawner ID + * @return Location string in format "world,x,y,z" or null if not found + */ + String getRawLocationString(String spawnerId); +} diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/data/storage/StorageMode.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/storage/StorageMode.java new file mode 100644 index 00000000..7a4c10a5 --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/storage/StorageMode.java @@ -0,0 +1,26 @@ +package github.nighter.smartspawner.spawner.data.storage; + +/** + * Enumeration of available storage modes for spawner data. + */ +public enum StorageMode { + /** + * File-based YAML storage (default). + * Spawner data is stored in spawners_data.yml + */ + YAML, + + /** + * MySQL/MariaDB database storage with HikariCP connection pool. + * Requires database server configuration in config.yml + * Supports cross-server spawner management. + */ + MYSQL, + + /** + * SQLite database storage with HikariCP connection pool. + * Local file-based database, no external server required. + * Good for single-server setups wanting database performance without MariaDB. + */ + SQLITE +} diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/gui/stacker/SpawnerStackerHandler.java b/core/src/main/java/github/nighter/smartspawner/spawner/gui/stacker/SpawnerStackerHandler.java index b7aed268..d104971a 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/gui/stacker/SpawnerStackerHandler.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/gui/stacker/SpawnerStackerHandler.java @@ -42,6 +42,7 @@ public class SpawnerStackerHandler implements Listener { private final LanguageManager languageManager; private final SpawnerItemFactory spawnerItemFactory; private final SpawnerLocationLockManager locationLockManager; + private final github.nighter.smartspawner.spawner.data.SpawnerManager spawnerManager; // Sound constants private static final Sound STACK_SOUND = Sound.ENTITY_EXPERIENCE_ORB_PICKUP; @@ -77,6 +78,7 @@ public SpawnerStackerHandler(SmartSpawner plugin) { this.spawnerItemFactory = plugin.getSpawnerItemFactory(); this.spawnerMenuUI = plugin.getSpawnerMenuUI(); this.locationLockManager = plugin.getSpawnerLocationLockManager(); + this.spawnerManager = plugin.getSpawnerManager(); // Start cleanup task - increased interval for less overhead startCleanupTask(); @@ -317,6 +319,10 @@ private void handleStackDecrease(Player player, SpawnerData spawner, int removeA // Update stack size and give spawners to player // setStackSize internally uses dataLock for thread safety spawner.setStackSize(targetSize); + + // Mark spawner as modified for database save + spawnerManager.markSpawnerModified(spawner.getSpawnerId()); + if (spawner.isItemSpawner()) { giveItemSpawnersToPlayer(player, actualChange, spawner.getSpawnedItemMaterial()); } else { @@ -399,6 +405,9 @@ private void handleStackIncrease(Player player, SpawnerData spawner, int changeA } spawner.setStackSize(currentSize + actualChange); + // Mark spawner as modified for database save + spawnerManager.markSpawnerModified(spawner.getSpawnerId()); + // Notify if max stack reached if (actualChange < changeAmount) { Map placeholders = new HashMap<>(2); diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/interactions/destroy/SpawnerBreakListener.java b/core/src/main/java/github/nighter/smartspawner/spawner/interactions/destroy/SpawnerBreakListener.java index b14efc8f..322dc799 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/interactions/destroy/SpawnerBreakListener.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/interactions/destroy/SpawnerBreakListener.java @@ -315,7 +315,7 @@ private void cleanupSpawner(Block block, SpawnerData spawner) { String spawnerId = spawner.getSpawnerId(); plugin.getRangeChecker().deactivateSpawner(spawner); spawnerManager.removeSpawner(spawnerId); - spawnerFileHandler.markSpawnerDeleted(spawnerId); + spawnerManager.markSpawnerDeleted(spawnerId); // Remove location lock to prevent memory leak Location location = block.getLocation(); diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/interactions/destroy/SpawnerExplosionListener.java b/core/src/main/java/github/nighter/smartspawner/spawner/interactions/destroy/SpawnerExplosionListener.java index 5c5c4e49..66830c48 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/interactions/destroy/SpawnerExplosionListener.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/interactions/destroy/SpawnerExplosionListener.java @@ -68,7 +68,7 @@ private void handleExplosion(EntityExplodeEvent event, List blockList) { e = new SpawnerExplodeEvent(null, spawnerData.getSpawnerLocation(), 1, true); } spawnerManager.removeSpawner(spawnerId); - spawnerFileHandler.markSpawnerDeleted(spawnerId); + spawnerManager.markSpawnerDeleted(spawnerId); } if (e != null) { Bukkit.getPluginManager().callEvent(e); diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/interactions/place/SpawnerPlaceListener.java b/core/src/main/java/github/nighter/smartspawner/spawner/interactions/place/SpawnerPlaceListener.java index 7671e58f..d5c438c3 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/interactions/place/SpawnerPlaceListener.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/interactions/place/SpawnerPlaceListener.java @@ -277,6 +277,25 @@ private EntityType getEntityType(EntityType storedEntityType, CreatureSpawner pl } private void createSmartSpawner(Block block, Player player, EntityType entityType, int stackSize) { + // Check if a spawner already exists at this location (prevent duplicates/ghost spawners) + SpawnerData existingSpawner = spawnerManager.getSpawnerByLocation(block.getLocation()); + if (existingSpawner != null) { + plugin.debug("Spawner already exists at " + block.getLocation() + " with ID " + existingSpawner.getSpawnerId()); + // Update the existing spawner instead of creating a duplicate + existingSpawner.updateLastInteractedPlayer(player.getName()); + if (existingSpawner.getEntityType() == entityType) { + // Same type - add to stack + int newStackSize = existingSpawner.getStackSize() + stackSize; + existingSpawner.setStackSize(Math.min(newStackSize, existingSpawner.getMaxStackSize())); + spawnerManager.queueSpawnerForSaving(existingSpawner.getSpawnerId()); + messageService.sendMessage(player, "spawner_stacked"); + } else { + // Different type - just activate it + messageService.sendMessage(player, "spawner_activated"); + } + return; + } + String spawnerId = UUID.randomUUID().toString().substring(0, 8); BlockState state = block.getState(false); @@ -288,7 +307,7 @@ private void createSmartSpawner(Block block, Player player, EntityType entityTyp SpawnerData spawner = new SpawnerData(spawnerId, block.getLocation(), entityType, plugin); spawner.setSpawnerActive(true); spawner.setStackSize(stackSize); - + // Track player interaction for last interaction field spawner.updateLastInteractedPlayer(player.getName()); spawnerManager.addSpawner(spawnerId, spawner); @@ -302,6 +321,25 @@ private void createSmartSpawner(Block block, Player player, EntityType entityTyp } private void createSmartItemSpawner(Block block, Player player, Material itemMaterial, int stackSize) { + // Check if a spawner already exists at this location (prevent duplicates/ghost spawners) + SpawnerData existingSpawner = spawnerManager.getSpawnerByLocation(block.getLocation()); + if (existingSpawner != null) { + plugin.debug("Item spawner already exists at " + block.getLocation() + " with ID " + existingSpawner.getSpawnerId()); + // Update the existing spawner instead of creating a duplicate + existingSpawner.updateLastInteractedPlayer(player.getName()); + if (existingSpawner.isItemSpawner() && existingSpawner.getSpawnedItemMaterial() == itemMaterial) { + // Same item type - add to stack + int newStackSize = existingSpawner.getStackSize() + stackSize; + existingSpawner.setStackSize(Math.min(newStackSize, existingSpawner.getMaxStackSize())); + spawnerManager.queueSpawnerForSaving(existingSpawner.getSpawnerId()); + messageService.sendMessage(player, "spawner_stacked"); + } else { + // Different type - just activate it + messageService.sendMessage(player, "spawner_activated"); + } + return; + } + String spawnerId = UUID.randomUUID().toString().substring(0, 8); BlockState state = block.getState(false); @@ -316,7 +354,7 @@ private void createSmartItemSpawner(Block block, Player player, Material itemMat SpawnerData spawner = new SpawnerData(spawnerId, block.getLocation(), itemMaterial, plugin); spawner.setSpawnerActive(true); spawner.setStackSize(stackSize); - + // Track player interaction for last interaction field spawner.updateLastInteractedPlayer(player.getName()); diff --git a/core/src/main/java/github/nighter/smartspawner/spawner/interactions/stack/SpawnerStackHandler.java b/core/src/main/java/github/nighter/smartspawner/spawner/interactions/stack/SpawnerStackHandler.java index 5c641361..e096725f 100644 --- a/core/src/main/java/github/nighter/smartspawner/spawner/interactions/stack/SpawnerStackHandler.java +++ b/core/src/main/java/github/nighter/smartspawner/spawner/interactions/stack/SpawnerStackHandler.java @@ -27,12 +27,14 @@ public class SpawnerStackHandler { private final SmartSpawner plugin; private final MessageService messageService; + private final github.nighter.smartspawner.spawner.data.SpawnerManager spawnerManager; private final Map lastStackTime; private final Map stackLocks; public SpawnerStackHandler(SmartSpawner plugin) { this.plugin = plugin; this.messageService = plugin.getMessageService(); + this.spawnerManager = plugin.getSpawnerManager(); this.lastStackTime = new ConcurrentHashMap<>(); this.stackLocks = new ConcurrentHashMap<>(); @@ -206,6 +208,10 @@ private boolean processStackAddition(Player player, SpawnerData targetSpawner, I // Update spawner data targetSpawner.setStackSize(newStack); + + // Mark spawner as modified for database save + spawnerManager.markSpawnerModified(targetSpawner.getSpawnerId()); + if (targetSpawner.getIsAtCapacity()) { targetSpawner.setIsAtCapacity(false); } @@ -255,4 +261,4 @@ private void showStackAnimation(SpawnerData spawner, int newStack, Player player placeholders.put("amount", String.valueOf(newStack)); messageService.sendMessage(player, "spawner_stack_success", placeholders); } -} \ No newline at end of file +} diff --git a/core/src/main/java/github/nighter/smartspawner/updates/ConfigUpdater.java b/core/src/main/java/github/nighter/smartspawner/updates/ConfigUpdater.java index dd13e329..8b8ed0f2 100644 --- a/core/src/main/java/github/nighter/smartspawner/updates/ConfigUpdater.java +++ b/core/src/main/java/github/nighter/smartspawner/updates/ConfigUpdater.java @@ -163,6 +163,9 @@ private Map flattenConfig(ConfigurationSection config) { * Applies the user values to the new config */ private void applyUserValues(FileConfiguration newConfig, Map userValues) { + // Apply renamed path migrations first + migrateRenamedPaths(userValues); + for (Map.Entry entry : userValues.entrySet()) { String path = entry.getKey(); Object value = entry.getValue(); @@ -173,7 +176,47 @@ private void applyUserValues(FileConfiguration newConfig, Map us if (newConfig.contains(path)) { newConfig.set(path, value); } else { - plugin.getLogger().warning("Config path '" + path + "' from old config no longer exists in new config"); + plugin.debug("Config path '" + path + "' from old config no longer exists in new config"); + } + } + } + + /** + * Migrates values from old renamed paths to new paths + */ + private void migrateRenamedPaths(Map userValues) { + // Map of old path -> new path for renamed config keys + Map renamedPaths = Map.ofEntries( + Map.entry("database.standalone.host", "database.sql.host"), + Map.entry("database.standalone.port", "database.sql.port"), + Map.entry("database.standalone.username", "database.sql.username"), + Map.entry("database.standalone.password", "database.sql.password"), + Map.entry("database.standalone.pool.maximum-size", "database.sql.pool.maximum-size"), + Map.entry("database.standalone.pool.minimum-idle", "database.sql.pool.minimum-idle"), + Map.entry("database.standalone.pool.connection-timeout", "database.sql.pool.connection-timeout"), + Map.entry("database.standalone.pool.max-lifetime", "database.sql.pool.max-lifetime"), + Map.entry("database.standalone.pool.idle-timeout", "database.sql.pool.idle-timeout"), + Map.entry("database.standalone.pool.keepalive-time", "database.sql.pool.keepalive-time"), + Map.entry("database.standalone.pool.leak-detection-threshold", "database.sql.pool.leak-detection-threshold") + ); + + for (Map.Entry rename : renamedPaths.entrySet()) { + String oldPath = rename.getKey(); + String newPath = rename.getValue(); + + if (userValues.containsKey(oldPath) && !userValues.containsKey(newPath)) { + Object value = userValues.remove(oldPath); + userValues.put(newPath, value); + plugin.debug("Migrated config: " + oldPath + " -> " + newPath); + } + } + + // Handle storage mode migration: DATABASE -> MYSQL + if (userValues.containsKey("database.mode")) { + Object mode = userValues.get("database.mode"); + if ("DATABASE".equals(mode)) { + userValues.put("database.mode", "MYSQL"); + plugin.getLogger().info("Migrated database.mode: DATABASE -> MYSQL"); } } } diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 05354341..10789b33 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -356,4 +356,78 @@ logging: # - name: "Action Time" # value: "{time}" - # inline: false \ No newline at end of file + # inline: false + +#--------------------------------------------------- +# Database Settings +#--------------------------------------------------- +# Configure database storage for spawner data. +# Database mode provides better performance for large servers +# and enables cross-server spawner management. +database: + # Storage mode: YAML, MYSQL, or SQLITE + # YAML: Default file-based storage (spawners_data.yml) + # MYSQL: MariaDB/MySQL database storage with HikariCP connection pool + # SQLITE: Local SQLite database storage (no external server required) + mode: YAML + + # Server identifier for cross-server setups + # Must be unique per server when using shared database + # Used to distinguish spawners from different servers + server_name: "server1" + + # Enable cross-server spawner viewing in /smartspawner list + # When true, shows a server selection page before world selection + # Allows viewing spawners from all servers in the shared database + # Only works when mode is MYSQL (SQLite is local only) + sync_across_servers: false + + # Automatic migration from local storage formats + # When enabled, the plugin will automatically migrate data on startup: + # + # 1. If mode is MYSQL or SQLITE: + # - Checks for spawners_data.yml and migrates to target database + # - File is renamed to spawners_data.yml.migrated after success + # + # 2. If mode is MYSQL (additional step): + # - Checks for spawners.db (SQLite) and migrates to MySQL + # - File is renamed to spawners.db.migrated after success + # + # The .migrated suffix prevents re-migration on subsequent restarts. + # Set to false if you want to manually manage your data migration. + migrate_from_local: true + + # Database name to use (only for MYSQL mode) + database: "smartspawner" + + # SQLite settings (only for SQLITE mode) + sqlite: + # Database file name (stored in plugin data folder) + file: "spawners.db" + + # MySQL/MariaDB connection settings (only for MYSQL mode) + sql: + host: "localhost" + port: 3306 + username: "root" + password: "" + + # Connection pool settings + pool: + # Maximum number of connections in the pool + maximum-size: 10 + # Minimum number of idle connections to maintain + minimum-idle: 2 + # Maximum time (ms) to wait for a connection from the pool + connection-timeout: 10000 + # Maximum lifetime (ms) of a connection in the pool + max-lifetime: 1800000 + # Maximum time (ms) a connection can sit idle before being removed + # Must be less than max-lifetime (0 = same as max-lifetime) + idle-timeout: 600000 + # Interval (ms) for keepalive queries to prevent connection timeouts + # Must be less than max-lifetime (0 = disabled) + keepalive-time: 30000 + # Time (ms) before logging a potential connection leak warning + # Useful for debugging connection issues (0 = disabled) + leak-detection-threshold: 0