From c570d05d13f0efda98ca7855797e3e2b08bd4a7b Mon Sep 17 00:00:00 2001 From: bedge117 <77640477+bedge117@users.noreply.github.com> Date: Wed, 28 Jan 2026 04:36:01 -0600 Subject: [PATCH 01/33] Add MariaDB database storage support - Implement SpawnerStorage interface for abstracted storage layer - Add DatabaseManager with HikariCP connection pool for MariaDB - Create SpawnerDatabaseHandler implementing storage interface with dirty tracking and async batch saves - Add YamlToDatabaseMigration for one-time migration from YAML to DB - Update SpawnerFileHandler to implement SpawnerStorage interface - Add server_name config for cross-server spawner identification - Configure shadow plugin to shade HikariCP and MariaDB JDBC driver - Storage mode configurable via database.mode (YAML or DATABASE) Default behavior unchanged - YAML mode remains the default. --- build.gradle | 1 + core/build.gradle | 45 ++ .../nighter/smartspawner/SmartSpawner.java | 78 ++- .../spawner/data/SpawnerFileHandler.java | 18 +- .../spawner/data/SpawnerManager.java | 15 +- .../spawner/data/WorldEventHandler.java | 10 +- .../data/database/DatabaseManager.java | 200 ++++++ .../data/database/SpawnerDatabaseHandler.java | 652 ++++++++++++++++++ .../database/YamlToDatabaseMigration.java | 343 +++++++++ .../spawner/data/storage/SpawnerStorage.java | 71 ++ .../spawner/data/storage/StorageMode.java | 18 + core/src/main/resources/config.yml | 40 +- 12 files changed, 1472 insertions(+), 19 deletions(-) create mode 100644 core/src/main/java/github/nighter/smartspawner/spawner/data/database/DatabaseManager.java create mode 100644 core/src/main/java/github/nighter/smartspawner/spawner/data/database/SpawnerDatabaseHandler.java create mode 100644 core/src/main/java/github/nighter/smartspawner/spawner/data/database/YamlToDatabaseMigration.java create mode 100644 core/src/main/java/github/nighter/smartspawner/spawner/data/storage/SpawnerStorage.java create mode 100644 core/src/main/java/github/nighter/smartspawner/spawner/data/storage/StorageMode.java 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..157e86c6 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,10 @@ 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' + // Hook plugins compileOnly 'org.geysermc.floodgate:api:2.2.5-SNAPSHOT' compileOnly 'com.sk89q.worldguard:worldguard-bukkit:7.1.0-SNAPSHOT' @@ -57,6 +71,37 @@ 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' + + 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..1ccabc00 100644 --- a/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java +++ b/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java @@ -44,6 +44,11 @@ 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.YamlToDatabaseMigration; import github.nighter.smartspawner.spawner.config.SpawnerMobHeadTexture; import github.nighter.smartspawner.spawner.lootgen.SpawnerLootGenerator; import github.nighter.smartspawner.spawner.data.WorldEventHandler; @@ -107,6 +112,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; @@ -262,7 +269,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 +283,64 @@ 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.DATABASE) { + getLogger().info("Initializing database storage mode..."); + this.databaseManager = new DatabaseManager(this); + + if (databaseManager.initialize()) { + SpawnerDatabaseHandler dbHandler = new SpawnerDatabaseHandler(this, databaseManager); + if (dbHandler.initialize()) { + this.spawnerStorage = dbHandler; + + // Check for YAML migration + YamlToDatabaseMigration migration = new YamlToDatabaseMigration(this, databaseManager); + if (migration.needsMigration()) { + getLogger().info("YAML data detected, starting migration to database..."); + if (migration.migrate()) { + getLogger().info("Migration completed successfully!"); + } else { + getLogger().warning("Migration completed with some errors. Check logs for details."); + } + } + + getLogger().info("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); @@ -439,8 +501,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 +521,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/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..ff9ed412 --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/DatabaseManager.java @@ -0,0 +1,200 @@ +package github.nighter.smartspawner.spawner.data.database; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import github.nighter.smartspawner.SmartSpawner; + +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 MariaDB for spawner data storage. + */ +public class DatabaseManager { + private final SmartSpawner plugin; + private final Logger logger; + 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; + + // Pool settings + private final int maxPoolSize; + private final int minIdle; + private final long connectionTimeout; + private final long maxLifetime; + + private static final String CREATE_TABLE_SQL = """ + 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 + """; + + public DatabaseManager(SmartSpawner plugin) { + this.plugin = plugin; + this.logger = plugin.getLogger(); + + // Load configuration + this.host = plugin.getConfig().getString("database.standalone.host", "localhost"); + this.port = plugin.getConfig().getInt("database.standalone.port", 3306); + this.database = plugin.getConfig().getString("database.database", "smartspawner"); + this.username = plugin.getConfig().getString("database.standalone.username", "root"); + this.password = plugin.getConfig().getString("database.standalone.password", ""); + this.serverName = plugin.getConfig().getString("database.server_name", "server1"); + + // Pool settings + this.maxPoolSize = plugin.getConfig().getInt("database.standalone.pool.maximum-size", 10); + this.minIdle = plugin.getConfig().getInt("database.standalone.pool.minimum-idle", 2); + this.connectionTimeout = plugin.getConfig().getLong("database.standalone.pool.connection-timeout", 10000); + this.maxLifetime = plugin.getConfig().getLong("database.standalone.pool.max-lifetime", 1800000); + } + + /** + * 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(); + + // JDBC URL for MariaDB + String jdbcUrl = String.format("jdbc:mariadb://%s:%d/%s?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=UTC", + host, port, database); + + config.setJdbcUrl(jdbcUrl); + config.setUsername(username); + config.setPassword(password); + + // Pool settings + config.setMaximumPoolSize(maxPoolSize); + config.setMinimumIdle(minIdle); + config.setConnectionTimeout(connectionTimeout); + config.setMaxLifetime(maxLifetime); + + // Performance settings + 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"); + + dataSource = new HikariDataSource(config); + } + + private void createTables() throws SQLException { + try (Connection conn = getConnection(); + Statement stmt = conn.createStatement()) { + stmt.execute(CREATE_TABLE_SQL); + 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; + } + + /** + * 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..5f42d326 --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/SpawnerDatabaseHandler.java @@ -0,0 +1,652 @@ +package github.nighter.smartspawner.spawner.data.database; + +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 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.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; +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 = ? + """; + + private static final String UPSERT_SQL = """ + 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 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; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(UPSERT_SQL)) { + + 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()); + } + } +} 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..c2f54c8a --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/YamlToDatabaseMigration.java @@ -0,0 +1,343 @@ +package github.nighter.smartspawner.spawner.data.database; + +import github.nighter.smartspawner.SmartSpawner; +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 MariaDB database. + * 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"; + + private static final String INSERT_SQL = """ + 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) + """; + + 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."); + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(INSERT_SQL)) { + + 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..387487bf --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/storage/StorageMode.java @@ -0,0 +1,18 @@ +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, + + /** + * MariaDB database storage with HikariCP connection pool. + * Requires database configuration in config.yml + */ + DATABASE +} diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 05354341..ea95e2ac 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -356,4 +356,42 @@ 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 or DATABASE + # YAML: Default file-based storage (spawners_data.yml) + # DATABASE: MariaDB database storage with HikariCP connection pool + 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" + + # Database name to use (only for DATABASE mode) + database: "smartspawner" + + # Connection settings for DATABASE mode + standalone: + 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 \ No newline at end of file From 8f96a97e0145f4af3f9cced89464fb468cf53909 Mon Sep 17 00:00:00 2001 From: bedge117 <77640477+bedge117@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:49:56 -0600 Subject: [PATCH 02/33] Fix duplicate spawner entries when placing at existing location Added location check in createSmartSpawner() and createSmartItemSpawner() before creating new spawner data. If a spawner already exists at that location, the code now: - Stacks with existing spawner if same entity/item type - Updates last interacted player - Prevents duplicate entries in spawners map This fixes ghost spawner entries where the physical block could be removed but orphaned data remained in the YAML. --- .../place/SpawnerPlaceListener.java | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) 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()); From 6be605b0e0085deede8e2a973146b43f09bb4ef6 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:44:30 -0600 Subject: [PATCH 03/33] Implement database storage and migration handling Added database storage support and migration from YAML. --- .../nighter/smartspawner/SmartSpawner.java | 82 +++++++++++++++++-- 1 file changed, 77 insertions(+), 5 deletions(-) diff --git a/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java b/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java index 738e0b1a..a5a6b11b 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,11 @@ 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.YamlToDatabaseMigration; import github.nighter.smartspawner.spawner.config.SpawnerMobHeadTexture; import github.nighter.smartspawner.spawner.lootgen.SpawnerLootGenerator; import github.nighter.smartspawner.spawner.data.WorldEventHandler; @@ -107,6 +113,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 +136,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 +271,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 +285,64 @@ 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.DATABASE) { + getLogger().info("Initializing database storage mode..."); + this.databaseManager = new DatabaseManager(this); + + if (databaseManager.initialize()) { + SpawnerDatabaseHandler dbHandler = new SpawnerDatabaseHandler(this, databaseManager); + if (dbHandler.initialize()) { + this.spawnerStorage = dbHandler; + + // Check for YAML migration + YamlToDatabaseMigration migration = new YamlToDatabaseMigration(this, databaseManager); + if (migration.needsMigration()) { + getLogger().info("YAML data detected, starting migration to database..."); + if (migration.migrate()) { + getLogger().info("Migration completed successfully!"); + } else { + getLogger().warning("Migration completed with some errors. Check logs for details."); + } + } + + getLogger().info("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 +417,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 +434,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 +505,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 +525,7 @@ private void saveAndCleanup() { if (itemPriceManager != null) { itemPriceManager.cleanup(); } - + // Shutdown logging system if (spawnerActionLogger != null) { spawnerActionLogger.shutdown(); From 6020ddc411b240974dc63692c23f04e34edaf5ea Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:45:35 -0600 Subject: [PATCH 04/33] Implement cross-server selection in ListSubCommand Added cross-server selection functionality to the ListSubCommand. Implemented methods for server selection GUI and cross-server checks. --- .../commands/list/ListSubCommand.java | 354 +++++++++++++++++- 1 file changed, 353 insertions(+), 1 deletion(-) 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..53c6c93f 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,224 @@ 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 DATABASE mode AND sync_across_servers = true + */ + public boolean isCrossServerEnabled() { + String modeStr = plugin.getConfig().getString("database.mode", "YAML").toUpperCase(); + try { + StorageMode mode = StorageMode.valueOf(modeStr); + if (mode != StorageMode.DATABASE) { + 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 +372,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 +721,134 @@ 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) { + 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); + 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; + dbHandler.getCrossServerSpawnersAsync(targetServer, worldName, 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, FilterOption.ALL, SortOption.DEFAULT, 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 (filter/sort disabled for remote) + // Previous page + if (currentPage > 1) { + inv.setItem(45, createNavigationButton(Material.SPECTRAL_ARROW, "navigation.previous_page")); + } + + // 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); } From dc5a9fbc26266614aeed33c1d5f56b820572a4cc Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:46:28 -0600 Subject: [PATCH 05/33] Add spawner modification marking after stack size update Mark spawner as modified for database save after updating stack size. --- .../commands/list/gui/adminstacker/AdminStackerHandler.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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..71e7c0dd 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 @@ -107,7 +107,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); From d8c7253c37d9deddfbbf7b98641e3510d2f823ff Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:47:26 -0600 Subject: [PATCH 06/33] Enhance world selection and spawner management logic Refactor event handling for world selection and spawner management. Added support for remote servers and improved code readability. --- .../list/gui/list/SpawnerListGUI.java | 131 +++++++++++++----- 1 file changed, 100 insertions(+), 31 deletions(-) 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..986c9f7f 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,69 @@ 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 - if (event.getSlot() == 48) { - // Cycle to next filter option - FilterOption nextFilter = currentFilter.getNextOption(); + // For remote servers, filter/sort buttons are disabled + if (!isRemote) { + // Handle filter button click + 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 + listSubCommand.saveUserPreference(player, worldName, nextFilter, currentSort); - listSubCommand.openSpawnerListGUI(player, worldName, 1, nextFilter, currentSort); - return; - } + listSubCommand.openSpawnerListGUI(player, worldName, 1, nextFilter, currentSort); + return; + } - // Handle sort button click - if (event.getSlot() == 50) { - // Cycle to next sort option - SortOption nextSort = currentSort.getNextOption(); + // Handle sort button click + 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 + listSubCommand.saveUserPreference(player, worldName, currentFilter, nextSort); - listSubCommand.openSpawnerListGUI(player, worldName, 1, currentFilter, nextSort); - return; + 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 +261,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"); + } } } } From 1ef21303000b7077b93d77f0af31a3ba58707842 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:48:02 -0600 Subject: [PATCH 07/33] Add targetServer field to SpawnerListHolder Added targetServer field and updated constructors to handle it. --- .../commands/list/gui/list/SpawnerListHolder.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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 From 4de7359fecb17cb1a0c7b2208c4e6ec1b7959d99 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:48:47 -0600 Subject: [PATCH 08/33] Enhance SpawnerManagementGUI for remote server support --- .../gui/management/SpawnerManagementGUI.java | 99 +++++++++++++++++-- 1 file changed, 89 insertions(+), 10 deletions(-) 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..f6ba5996 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,63 @@ 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 + if (isRemote) { + createDisabledTeleportItem(inv, TELEPORT_SLOT, targetServer); + } else { + createActionItem(inv, TELEPORT_SLOT, "spawner_management.teleport", Material.ENDER_PEARL); + } + + // Open spawner button - disabled for remote servers + if (isRemote) { + createDisabledActionItem(inv, OPEN_SPAWNER_SLOT, "spawner_management.open_spawner", Material.ENDER_EYE, "Remote Server"); + } else { + createActionItem(inv, OPEN_SPAWNER_SLOT, "spawner_management.open_spawner", Material.ENDER_EYE); + } + + // Stack button - disabled for remote servers + if (isRemote) { + createDisabledActionItem(inv, STACK_SLOT, "spawner_management.stack", Material.SPAWNER, "Remote Server"); + } else { + createActionItem(inv, STACK_SLOT, "spawner_management.stack", Material.SPAWNER); + } + + // Remove button - disabled for remote servers + if (isRemote) { + createDisabledActionItem(inv, REMOVE_SLOT, "spawner_management.remove", Material.BARRIER, "Remote Server"); + } 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 +114,40 @@ 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 String getCurrentServerName() { + return plugin.getConfig().getString("database.server_name", "server1"); + } +} From 85ac6e8400d11d06c724b17389bc6fb571d237bc Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:49:21 -0600 Subject: [PATCH 09/33] Refactor spawner management to use SpawnerStorage --- .../management/SpawnerManagementHandler.java | 42 +++++++++++++------ 1 file changed, 30 insertions(+), 12 deletions(-) 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..72daafaf 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 @@ -9,7 +9,7 @@ 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.storage.SpawnerStorage; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Sound; @@ -26,7 +26,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 +35,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 +52,35 @@ 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, all other actions are disabled + if (isRemote) { + player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f); + 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 +129,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,7 +152,12 @@ 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); } From 4efc6981e7c7650e6bdec08935f0aeb058f7c872 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:49:51 -0600 Subject: [PATCH 10/33] Add targetServer field and isRemoteServer method Added targetServer field and updated constructors to initialize it. Implemented isRemoteServer method to check for remote server. --- .../gui/management/SpawnerManagementHolder.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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 +} From 8df0b060adddfd70795bfa7b9e4e7315a9ebd97e Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:50:35 -0600 Subject: [PATCH 11/33] Implement WorldSelectionHolder for server-specific viewing Added support for cross-server world selection with target server name. --- .../worldselection/WorldSelectionHolder.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) 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; From 4176f66c9e326949b6c180cf5e75ec5a969acb3e Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:53:39 -0600 Subject: [PATCH 12/33] Implement DatabaseManager for HikariCP connection pooling This class manages database connections using HikariCP, specifically for MariaDB, and includes methods for initializing the connection pool, creating necessary tables, and managing connections. --- .../data/database/DatabaseManager.java | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 core/src/main/java/github/nighter/smartspawner/spawner/data/database/DatabaseManager.java 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..dee4d37f --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/DatabaseManager.java @@ -0,0 +1,210 @@ +package github.nighter.smartspawner.spawner.data.database; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import github.nighter.smartspawner.SmartSpawner; + +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 MariaDB for spawner data storage. + */ +public class DatabaseManager { + private final SmartSpawner plugin; + private final Logger logger; + 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; + + // 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; + + private static final String CREATE_TABLE_SQL = """ + 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 + """; + + public DatabaseManager(SmartSpawner plugin) { + this.plugin = plugin; + this.logger = plugin.getLogger(); + + // Load configuration + this.host = plugin.getConfig().getString("database.standalone.host", "localhost"); + this.port = plugin.getConfig().getInt("database.standalone.port", 3306); + this.database = plugin.getConfig().getString("database.database", "smartspawner"); + this.username = plugin.getConfig().getString("database.standalone.username", "root"); + this.password = plugin.getConfig().getString("database.standalone.password", ""); + this.serverName = plugin.getConfig().getString("database.server_name", "server1"); + + // Pool settings + this.maxPoolSize = plugin.getConfig().getInt("database.standalone.pool.maximum-size", 10); + this.minIdle = plugin.getConfig().getInt("database.standalone.pool.minimum-idle", 2); + this.connectionTimeout = plugin.getConfig().getLong("database.standalone.pool.connection-timeout", 10000); + this.maxLifetime = plugin.getConfig().getLong("database.standalone.pool.max-lifetime", 1800000); + this.idleTimeout = plugin.getConfig().getLong("database.standalone.pool.idle-timeout", 600000); + this.keepaliveTime = plugin.getConfig().getLong("database.standalone.pool.keepalive-time", 30000); + this.leakDetectionThreshold = plugin.getConfig().getLong("database.standalone.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(); + + // JDBC URL for MariaDB + 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 + 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"); + + dataSource = new HikariDataSource(config); + } + + private void createTables() throws SQLException { + try (Connection conn = getConnection(); + Statement stmt = conn.createStatement()) { + stmt.execute(CREATE_TABLE_SQL); + 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; + } + + /** + * 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."); + } + } +} From 58e5a48f27489521e1eb87b346c70bafdaf1c091 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:53:57 -0600 Subject: [PATCH 13/33] Add files via upload --- .../data/database/SpawnerDatabaseHandler.java | 872 ++++++++++++++++++ .../database/YamlToDatabaseMigration.java | 343 +++++++ 2 files changed, 1215 insertions(+) create mode 100644 core/src/main/java/github/nighter/smartspawner/spawner/data/database/SpawnerDatabaseHandler.java create mode 100644 core/src/main/java/github/nighter/smartspawner/spawner/data/database/YamlToDatabaseMigration.java 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..0135fdf1 --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/SpawnerDatabaseHandler.java @@ -0,0 +1,872 @@ +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.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 = ? + """; + + private static final String UPSERT_SQL = """ + 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 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; + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(UPSERT_SQL)) { + + 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)); + }); + } + + /** + * 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/YamlToDatabaseMigration.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/YamlToDatabaseMigration.java new file mode 100644 index 00000000..bebc95f5 --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/YamlToDatabaseMigration.java @@ -0,0 +1,343 @@ +package github.nighter.smartspawner.spawner.data.database; + +import github.nighter.smartspawner.SmartSpawner; +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 MariaDB database. + * 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"; + + private static final String INSERT_SQL = """ + 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) + """; + + 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."); + + try (Connection conn = databaseManager.getConnection(); + PreparedStatement stmt = conn.prepareStatement(INSERT_SQL)) { + + 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(); + } +} From e2396500b1289100107bf14fa6d83132e22a6a85 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:55:54 -0600 Subject: [PATCH 14/33] Integrate SpawnerManager for spawner modifications Added spawnerManager to handle spawner modifications. --- .../spawner/gui/stacker/SpawnerStackerHandler.java | 9 +++++++++ 1 file changed, 9 insertions(+) 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); From 708320a4c61ebdda3ea0011962adf0094c97b71d Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:56:59 -0600 Subject: [PATCH 15/33] Update spawner deletion handling in SpawnerBreakListener --- .../spawner/interactions/destroy/SpawnerBreakListener.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); From 791658494d5643e52f1823f0591c87aca6ceece5 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:57:37 -0600 Subject: [PATCH 16/33] Integrate SpawnerManager for spawner updates Added spawnerManager to handle spawner modifications. --- .../spawner/interactions/stack/SpawnerStackHandler.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 +} From 0c552dadc16a8ed66f1fe80c0c522c701660b031 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:58:28 -0600 Subject: [PATCH 17/33] Add database settings to config.yml Added database configuration settings for spawner data storage. --- core/src/main/resources/config.yml | 55 +++++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 05354341..253e6648 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -356,4 +356,57 @@ 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 or DATABASE + # YAML: Default file-based storage (spawners_data.yml) + # DATABASE: MariaDB database storage with HikariCP connection pool + 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 DATABASE + sync_across_servers: false + + # Database name to use (only for DATABASE mode) + database: "smartspawner" + + # Connection settings for DATABASE mode + standalone: + 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 From 5b6f9cf5a29d83d5635cc380e79412088d59ef9c Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:02:06 -0600 Subject: [PATCH 18/33] Add ServerSelectionHandler for GUI interactions Implements a listener for handling server selection clicks in the GUI. --- .../ServerSelectionHandler.java | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 core/src/main/java/github/nighter/smartspawner/commands/list/gui/serverselection/ServerSelectionHandler.java 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); + } +} From 3d44ab18d50d5d53cd727148ce1e19135e1987a8 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:02:20 -0600 Subject: [PATCH 19/33] Add files via upload --- .../serverselection/ServerSelectionHolder.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 core/src/main/java/github/nighter/smartspawner/commands/list/gui/serverselection/ServerSelectionHolder.java 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..beeb8128 --- /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; + } +} From f25041b3b453d89ab43e66851c7227efe63a0f0c Mon Sep 17 00:00:00 2001 From: bedge117 <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:07:35 -0600 Subject: [PATCH 20/33] Fix database mode bugs and add cross-server spawner list feature Database fixes: - Fix MariaDB driver not found (explicitly set relocated driver class) - Fix stack size not persisting (add markSpawnerModified calls in GUI/physical stacking) - Fix NPE on spawner break/explosion in database mode (use spawnerManager instead of spawnerFileHandler) Cross-server feature: - Add server selection GUI for viewing spawners across multiple servers - Add CrossServerSpawnerData for remote server spawner display - Add back button to world selection when cross-server mode enabled - Support remote world selection with async database queries --- .../nighter/smartspawner/SmartSpawner.java | 4 + .../commands/list/ListSubCommand.java | 354 +++++++++++++++++- .../list/gui/CrossServerSpawnerData.java | 99 +++++ .../gui/adminstacker/AdminStackerHandler.java | 5 +- .../list/gui/list/SpawnerListGUI.java | 131 +++++-- .../list/gui/list/SpawnerListHolder.java | 14 + .../gui/management/SpawnerManagementGUI.java | 99 ++++- .../management/SpawnerManagementHandler.java | 42 ++- .../management/SpawnerManagementHolder.java | 15 +- .../ServerSelectionHandler.java | 49 +++ .../ServerSelectionHolder.java | 16 + .../worldselection/WorldSelectionHolder.java | 37 ++ .../data/database/DatabaseManager.java | 10 + .../data/database/SpawnerDatabaseHandler.java | 220 +++++++++++ .../gui/stacker/SpawnerStackerHandler.java | 9 + .../destroy/SpawnerBreakListener.java | 2 +- .../destroy/SpawnerExplosionListener.java | 2 +- .../stack/SpawnerStackHandler.java | 6 + core/src/main/resources/config.yml | 17 +- 19 files changed, 1072 insertions(+), 59 deletions(-) create mode 100644 core/src/main/java/github/nighter/smartspawner/commands/list/gui/CrossServerSpawnerData.java create mode 100644 core/src/main/java/github/nighter/smartspawner/commands/list/gui/serverselection/ServerSelectionHandler.java create mode 100644 core/src/main/java/github/nighter/smartspawner/commands/list/gui/serverselection/ServerSelectionHolder.java diff --git a/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java b/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java index 1ccabc00..a5a6b11b 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; @@ -135,6 +136,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 @@ -415,6 +417,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 @@ -431,6 +434,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); } 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..53c6c93f 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,224 @@ 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 DATABASE mode AND sync_across_servers = true + */ + public boolean isCrossServerEnabled() { + String modeStr = plugin.getConfig().getString("database.mode", "YAML").toUpperCase(); + try { + StorageMode mode = StorageMode.valueOf(modeStr); + if (mode != StorageMode.DATABASE) { + 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 +372,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 +721,134 @@ 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) { + 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); + 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; + dbHandler.getCrossServerSpawnersAsync(targetServer, worldName, 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, FilterOption.ALL, SortOption.DEFAULT, 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 (filter/sort disabled for remote) + // Previous page + if (currentPage > 1) { + inv.setItem(45, createNavigationButton(Material.SPECTRAL_ARROW, "navigation.previous_page")); + } + + // 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..71e7c0dd 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 @@ -107,7 +107,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); 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..986c9f7f 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,69 @@ 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 - if (event.getSlot() == 48) { - // Cycle to next filter option - FilterOption nextFilter = currentFilter.getNextOption(); + // For remote servers, filter/sort buttons are disabled + if (!isRemote) { + // Handle filter button click + 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 + listSubCommand.saveUserPreference(player, worldName, nextFilter, currentSort); - listSubCommand.openSpawnerListGUI(player, worldName, 1, nextFilter, currentSort); - return; - } + listSubCommand.openSpawnerListGUI(player, worldName, 1, nextFilter, currentSort); + return; + } - // Handle sort button click - if (event.getSlot() == 50) { - // Cycle to next sort option - SortOption nextSort = currentSort.getNextOption(); + // Handle sort button click + 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 + listSubCommand.saveUserPreference(player, worldName, currentFilter, nextSort); - listSubCommand.openSpawnerListGUI(player, worldName, 1, currentFilter, nextSort); - return; + 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 +261,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..f6ba5996 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,63 @@ 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 + if (isRemote) { + createDisabledTeleportItem(inv, TELEPORT_SLOT, targetServer); + } else { + createActionItem(inv, TELEPORT_SLOT, "spawner_management.teleport", Material.ENDER_PEARL); + } + + // Open spawner button - disabled for remote servers + if (isRemote) { + createDisabledActionItem(inv, OPEN_SPAWNER_SLOT, "spawner_management.open_spawner", Material.ENDER_EYE, "Remote Server"); + } else { + createActionItem(inv, OPEN_SPAWNER_SLOT, "spawner_management.open_spawner", Material.ENDER_EYE); + } + + // Stack button - disabled for remote servers + if (isRemote) { + createDisabledActionItem(inv, STACK_SLOT, "spawner_management.stack", Material.SPAWNER, "Remote Server"); + } else { + createActionItem(inv, STACK_SLOT, "spawner_management.stack", Material.SPAWNER); + } + + // Remove button - disabled for remote servers + if (isRemote) { + createDisabledActionItem(inv, REMOVE_SLOT, "spawner_management.remove", Material.BARRIER, "Remote Server"); + } 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 +114,40 @@ 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 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..72daafaf 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 @@ -9,7 +9,7 @@ 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.storage.SpawnerStorage; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Sound; @@ -26,7 +26,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 +35,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 +52,35 @@ 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, all other actions are disabled + if (isRemote) { + player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f); + 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 +129,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,7 +152,12 @@ 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); } 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/database/DatabaseManager.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/DatabaseManager.java index ff9ed412..dee4d37f 100644 --- 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 @@ -32,6 +32,9 @@ public class DatabaseManager { private final int minIdle; private final long connectionTimeout; private final long maxLifetime; + private final long idleTimeout; + private final long keepaliveTime; + private final long leakDetectionThreshold; private static final String CREATE_TABLE_SQL = """ CREATE TABLE IF NOT EXISTS smart_spawners ( @@ -101,6 +104,9 @@ public DatabaseManager(SmartSpawner plugin) { this.minIdle = plugin.getConfig().getInt("database.standalone.pool.minimum-idle", 2); this.connectionTimeout = plugin.getConfig().getLong("database.standalone.pool.connection-timeout", 10000); this.maxLifetime = plugin.getConfig().getLong("database.standalone.pool.max-lifetime", 1800000); + this.idleTimeout = plugin.getConfig().getLong("database.standalone.pool.idle-timeout", 600000); + this.keepaliveTime = plugin.getConfig().getLong("database.standalone.pool.keepalive-time", 30000); + this.leakDetectionThreshold = plugin.getConfig().getLong("database.standalone.pool.leak-detection-threshold", 0); } /** @@ -127,6 +133,7 @@ private void setupDataSource() { host, port, database); config.setJdbcUrl(jdbcUrl); + config.setDriverClassName("github.nighter.smartspawner.libs.mariadb.Driver"); config.setUsername(username); config.setPassword(password); @@ -135,6 +142,9 @@ private void setupDataSource() { config.setMinimumIdle(minIdle); config.setConnectionTimeout(connectionTimeout); config.setMaxLifetime(maxLifetime); + config.setIdleTimeout(idleTimeout); + config.setKeepaliveTime(keepaliveTime); + config.setLeakDetectionThreshold(leakDetectionThreshold); // Performance settings config.setPoolName("SmartSpawner-HikariCP"); 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 index 5f42d326..dee912d3 100644 --- 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 @@ -2,6 +2,7 @@ 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.properties.SpawnerData; import github.nighter.smartspawner.spawner.properties.VirtualInventory; @@ -14,9 +15,11 @@ 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; /** @@ -649,4 +652,221 @@ private void loadInventoryFromJson(String jsonData, VirtualInventory virtualInv) 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)); + }); + } + + /** + * 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/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/stack/SpawnerStackHandler.java b/core/src/main/java/github/nighter/smartspawner/spawner/interactions/stack/SpawnerStackHandler.java index 5c641361..525181d8 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); } diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index ea95e2ac..a2fda829 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -375,6 +375,12 @@ 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 DATABASE + sync_across_servers: false + # Database name to use (only for DATABASE mode) database: "smartspawner" @@ -394,4 +400,13 @@ database: # 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 \ No newline at end of file + 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 \ No newline at end of file From 7bad97f2747bf225cfca296270d198b453414cd6 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:09:41 -0600 Subject: [PATCH 21/33] Change spawner deletion method in SpawnerExplosionListener --- .../spawner/interactions/destroy/SpawnerExplosionListener.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); From 153608c4637b991fa8a4169e29f12d35cf2e3b7f Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:10:43 -0600 Subject: [PATCH 22/33] Add files via upload --- .../list/gui/CrossServerSpawnerData.java | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 core/src/main/java/github/nighter/smartspawner/commands/list/gui/CrossServerSpawnerData.java 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..cffbd6a3 --- /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); + } +} From 98754076d9049e3a43c09d9931315713fbc3b638 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:12:22 -0600 Subject: [PATCH 23/33] Add SpawnerStorage interface for data management This interface defines the operations for managing spawner data storage, including initialization, loading, and saving spawners. --- .../spawner/data/storage/SpawnerStorage.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 core/src/main/java/github/nighter/smartspawner/spawner/data/storage/SpawnerStorage.java 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); +} From 135814a445b09d90db553b617e38dcaeb4e8f8e6 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:12:45 -0600 Subject: [PATCH 24/33] Create StorageMode enum for storage options Added an enumeration for storage modes including YAML and DATABASE. --- .../spawner/data/storage/StorageMode.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 core/src/main/java/github/nighter/smartspawner/spawner/data/storage/StorageMode.java 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..387487bf --- /dev/null +++ b/core/src/main/java/github/nighter/smartspawner/spawner/data/storage/StorageMode.java @@ -0,0 +1,18 @@ +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, + + /** + * MariaDB database storage with HikariCP connection pool. + * Requires database configuration in config.yml + */ + DATABASE +} From 745dee97b0de561a9165340abedc5d6f5386d6f9 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:13:34 -0600 Subject: [PATCH 25/33] Add shadow plugin to build.gradle --- build.gradle | 1 + 1 file changed, 1 insertion(+) 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 { From ed824dba59dd6594f09c3655b18926d15066ece6 Mon Sep 17 00:00:00 2001 From: Cd3daddy <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:14:07 -0600 Subject: [PATCH 26/33] Add shadow plugin and configure shaded dependencies --- core/build.gradle | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/core/build.gradle b/core/build.gradle index 5109fc1b..157e86c6 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,10 @@ 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' + // Hook plugins compileOnly 'org.geysermc.floodgate:api:2.2.5-SNAPSHOT' compileOnly 'com.sk89q.worldguard:worldguard-bukkit:7.1.0-SNAPSHOT' @@ -57,6 +71,37 @@ 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' + + 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 From 77c443c25f5d22e105d8fe0a561da94cbb353ac2 Mon Sep 17 00:00:00 2001 From: bedge117 <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:30:15 -0600 Subject: [PATCH 27/33] Add SQLite storage support and rename config section - Add SQLite as alternative to MySQL/MariaDB for local database storage - Update StorageMode enum: YAML, MYSQL, SQLITE (removed DATABASE) - Rename config section from standalone: to sql: for clarity - Add sqlite.file config option for SQLite database filename - SQLite uses single connection pool with WAL journal mode - Cross-server sync only available for MYSQL mode (SQLite is local-only) - Add xerial sqlite-jdbc driver with proper relocation --- core/build.gradle | 2 + .../nighter/smartspawner/SmartSpawner.java | 9 +- .../commands/list/ListSubCommand.java | 5 +- .../data/database/DatabaseManager.java | 152 +++++++++++++++--- .../spawner/data/storage/StorageMode.java | 14 +- core/src/main/resources/config.yml | 18 ++- 6 files changed, 167 insertions(+), 33 deletions(-) diff --git a/core/build.gradle b/core/build.gradle index 157e86c6..0f412596 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -18,6 +18,7 @@ dependencies { // 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' @@ -84,6 +85,7 @@ shadowJar { // 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' + relocate 'org.sqlite', 'github.nighter.smartspawner.libs.sqlite' exclude 'META-INF/*.RSA', 'META-INF/*.SF', 'META-INF/*.DSA' // Exclude unnecessary files from dependencies diff --git a/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java b/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java index a5a6b11b..87d341aa 100644 --- a/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java +++ b/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java @@ -300,9 +300,10 @@ private void initializeStorage() { mode = StorageMode.YAML; } - if (mode == StorageMode.DATABASE) { - getLogger().info("Initializing database storage mode..."); - this.databaseManager = new DatabaseManager(this); + 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); @@ -320,7 +321,7 @@ private void initializeStorage() { } } - getLogger().info("Database storage initialized successfully."); + getLogger().info(dbType + " database storage initialized successfully."); } else { getLogger().severe("Failed to initialize database handler, falling back to YAML"); databaseManager.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 53c6c93f..ef3030e3 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 @@ -82,13 +82,14 @@ public int execute(CommandContext context) { /** * Check if cross-server sync is enabled. - * Requires DATABASE mode AND sync_across_servers = true + * 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.DATABASE) { + if (mode != StorageMode.MYSQL) { return false; } } catch (IllegalArgumentException e) { 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 index dee4d37f..fe0c7819 100644 --- 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 @@ -3,7 +3,9 @@ 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; @@ -12,11 +14,12 @@ /** * Manages database connections using HikariCP connection pool. - * Supports MariaDB for spawner data storage. + * 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 @@ -26,6 +29,7 @@ public class DatabaseManager { private final String username; private final String password; private final String serverName; + private final String sqliteFile; // Pool settings private final int maxPoolSize; @@ -36,7 +40,8 @@ public class DatabaseManager { private final long keepaliveTime; private final long leakDetectionThreshold; - private static final String CREATE_TABLE_SQL = """ + // 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, @@ -87,26 +92,84 @@ INDEX idx_world (server_name, world_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """; - public DatabaseManager(SmartSpawner plugin) { + // 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.standalone.host", "localhost"); - this.port = plugin.getConfig().getInt("database.standalone.port", 3306); + 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.standalone.username", "root"); - this.password = plugin.getConfig().getString("database.standalone.password", ""); + 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.standalone.pool.maximum-size", 10); - this.minIdle = plugin.getConfig().getInt("database.standalone.pool.minimum-idle", 2); - this.connectionTimeout = plugin.getConfig().getLong("database.standalone.pool.connection-timeout", 10000); - this.maxLifetime = plugin.getConfig().getLong("database.standalone.pool.max-lifetime", 1800000); - this.idleTimeout = plugin.getConfig().getLong("database.standalone.pool.idle-timeout", 600000); - this.keepaliveTime = plugin.getConfig().getLong("database.standalone.pool.keepalive-time", 30000); - this.leakDetectionThreshold = plugin.getConfig().getLong("database.standalone.pool.leak-detection-threshold", 0); + 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); } /** @@ -128,7 +191,17 @@ public boolean initialize() { private void setupDataSource() { HikariConfig config = new HikariConfig(); - // JDBC URL for MariaDB + 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); @@ -146,7 +219,7 @@ private void setupDataSource() { config.setKeepaliveTime(keepaliveTime); config.setLeakDetectionThreshold(leakDetectionThreshold); - // Performance settings + // Performance settings for MySQL/MariaDB config.setPoolName("SmartSpawner-HikariCP"); config.addDataSourceProperty("cachePrepStmts", "true"); config.addDataSourceProperty("prepStmtCacheSize", "250"); @@ -158,14 +231,49 @@ private void setupDataSource() { config.addDataSourceProperty("cacheServerConfiguration", "true"); config.addDataSourceProperty("elideSetAutoCommits", "true"); config.addDataSourceProperty("maintainTimeStats", "false"); + } - dataSource = new HikariDataSource(config); + 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("github.nighter.smartspawner.libs.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()) { - stmt.execute(CREATE_TABLE_SQL); + + 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."); } } @@ -190,6 +298,14 @@ 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 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 index 387487bf..7a4c10a5 100644 --- 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 @@ -11,8 +11,16 @@ public enum StorageMode { YAML, /** - * MariaDB database storage with HikariCP connection pool. - * Requires database configuration in config.yml + * MySQL/MariaDB database storage with HikariCP connection pool. + * Requires database server configuration in config.yml + * Supports cross-server spawner management. */ - DATABASE + 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/resources/config.yml b/core/src/main/resources/config.yml index a2fda829..5d5bc6e7 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -365,9 +365,10 @@ logging: # Database mode provides better performance for large servers # and enables cross-server spawner management. database: - # Storage mode: YAML or DATABASE + # Storage mode: YAML, MYSQL, or SQLITE # YAML: Default file-based storage (spawners_data.yml) - # DATABASE: MariaDB database storage with HikariCP connection pool + # 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 @@ -378,14 +379,19 @@ database: # 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 DATABASE + # Only works when mode is MYSQL (SQLite is local only) sync_across_servers: false - # Database name to use (only for DATABASE mode) + # Database name to use (only for MYSQL mode) database: "smartspawner" - # Connection settings for DATABASE mode - standalone: + # 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" From 2f17905873e451e7782edfcce344888997c6437f Mon Sep 17 00:00:00 2001 From: bedge117 <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:58:14 -0600 Subject: [PATCH 28/33] Fix SQLite - remove relocation (JNI native libs cannot be relocated) --- core/build.gradle | 2 +- .../smartspawner/spawner/data/database/DatabaseManager.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/core/build.gradle b/core/build.gradle index 0f412596..c4fda557 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -85,7 +85,7 @@ shadowJar { // 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' - relocate 'org.sqlite', 'github.nighter.smartspawner.libs.sqlite' + // 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 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 index fe0c7819..792ab7ad 100644 --- 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 @@ -245,7 +245,7 @@ private void setupSQLiteDataSource(HikariConfig config) { String jdbcUrl = "jdbc:sqlite:" + dbFile.getAbsolutePath(); config.setJdbcUrl(jdbcUrl); - config.setDriverClassName("github.nighter.smartspawner.libs.sqlite.JDBC"); + 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 From 881dad3f79c6f43ede8f55aa522cfd4ed26cbfc4 Mon Sep 17 00:00:00 2001 From: bedge117 <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:25:26 -0600 Subject: [PATCH 29/33] Fix SQLite upsert - use ON CONFLICT syntax instead of MySQL ON DUPLICATE KEY --- .../data/database/SpawnerDatabaseHandler.java | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) 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 index dee912d3..6008fe53 100644 --- 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 @@ -4,6 +4,7 @@ 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; @@ -66,7 +67,8 @@ public class SpawnerDatabaseHandler implements SpawnerStorage { WHERE server_name = ? AND spawner_id = ? """; - private static final String UPSERT_SQL = """ + // 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, @@ -101,6 +103,42 @@ INSERT INTO smart_spawners ( 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 = ? """; @@ -206,8 +244,13 @@ public void flushChanges() { 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(UPSERT_SQL)) { + PreparedStatement stmt = conn.prepareStatement(upsertSql)) { conn.setAutoCommit(false); From a6bbde8a72b1dd8ef5ac18fd24b17f7ae6497626 Mon Sep 17 00:00:00 2001 From: bedge117 <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:38:06 -0600 Subject: [PATCH 30/33] Add migration support for YAML->SQLite, YAML->MySQL, and SQLite->MySQL - YamlToDatabaseMigration now supports both MySQL and SQLite syntax - New SqliteToMySqlMigration class for migrating from SQLite to MariaDB - Migrations run automatically on startup when target mode is detected - Source files are renamed with .migrated suffix after successful migration --- .../nighter/smartspawner/SmartSpawner.java | 28 ++- .../data/database/SqliteToMySqlMigration.java | 229 ++++++++++++++++++ .../database/YamlToDatabaseMigration.java | 49 +++- 3 files changed, 296 insertions(+), 10 deletions(-) create mode 100644 core/src/main/java/github/nighter/smartspawner/spawner/data/database/SqliteToMySqlMigration.java diff --git a/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java b/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java index 87d341aa..030c7b0d 100644 --- a/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java +++ b/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java @@ -49,6 +49,7 @@ 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; @@ -310,14 +311,27 @@ private void initializeStorage() { if (dbHandler.initialize()) { this.spawnerStorage = dbHandler; - // Check for YAML migration - YamlToDatabaseMigration migration = new YamlToDatabaseMigration(this, databaseManager); - if (migration.needsMigration()) { - getLogger().info("YAML data detected, starting migration to database..."); - if (migration.migrate()) { - getLogger().info("Migration completed successfully!"); + // 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("Migration completed with some errors. Check logs for details."); + 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."); + } } } 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 index c2f54c8a..7d29c8bc 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -23,7 +24,7 @@ import java.util.stream.Collectors; /** - * Handles one-time migration from spawners_data.yml to MariaDB database. + * 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. */ @@ -36,7 +37,8 @@ public class YamlToDatabaseMigration { private static final String YAML_FILE_NAME = "spawners_data.yml"; private static final String MIGRATED_FILE_SUFFIX = ".migrated"; - private static final String INSERT_SQL = """ + // 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, @@ -71,6 +73,42 @@ INSERT INTO smart_spawners ( 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(); @@ -128,8 +166,13 @@ public boolean migrate() { 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(INSERT_SQL)) { + PreparedStatement stmt = conn.prepareStatement(insertSql)) { conn.setAutoCommit(false); int batchCount = 0; From 243023ce2b8e7de3d32b5cec1ae3d15ca0dc18ad Mon Sep 17 00:00:00 2001 From: bedge117 <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:43:36 -0600 Subject: [PATCH 31/33] Add migrate_from_local config option for database migrations Added database.migrate_from_local option (default: true) that controls automatic data migration on startup. When enabled and using MYSQL or SQLITE mode: - Migrates spawners_data.yml to target database - For MYSQL mode, also migrates spawners.db (SQLite) to MySQL - Files are renamed with .migrated suffix after successful migration Config includes detailed comments explaining the migration behavior. --- .../nighter/smartspawner/SmartSpawner.java | 43 +++++++++++-------- core/src/main/resources/config.yml | 15 +++++++ 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java b/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java index 030c7b0d..8f534cd7 100644 --- a/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java +++ b/core/src/main/java/github/nighter/smartspawner/SmartSpawner.java @@ -311,28 +311,35 @@ private void initializeStorage() { if (dbHandler.initialize()) { this.spawnerStorage = dbHandler; - // 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 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."); + // 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."); diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index 83748c18..10789b33 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -382,6 +382,21 @@ 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" From ef20587c5e1a17d99b6ef4bf3f4557312c072f73 Mon Sep 17 00:00:00 2001 From: bedge117 <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:49:14 -0600 Subject: [PATCH 32/33] Add config migration for renamed database.standalone to database.sql Automatically migrates old config keys when upgrading: - database.standalone.* -> database.sql.* - database.mode: DATABASE -> MYSQL New keys like migrate_from_local and sqlite.file are added automatically by the existing ConfigUpdater mechanism. --- .../smartspawner/updates/ConfigUpdater.java | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) 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"); } } } From 0c90a3cc36d7fe0074fac3be661b1f323affde00 Mon Sep 17 00:00:00 2001 From: bedge117 <77640477+bedge117@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:14:03 -0600 Subject: [PATCH 33/33] Add cross-server filter/sort and remote spawner management Cross-server spawner list improvements: - Filter (all/active/inactive) now works for remote server spawners - Sort (default/stack size asc/desc) now works for remote server spawners - Filter/sort buttons added to remote spawner list GUI Remote spawner management: - View Spawner Info: Shows spawner stats from database via chat - Edit Stack Size: Opens stack editor, saves changes to database async - Remove Spawner: Deletes from database (physical block syncs on target server refresh) - Teleport remains disabled (cannot teleport cross-server) Technical changes: - Added getCrossServerSpawnersAsync with filter/sort parameters - Added getRemoteSpawnerByIdAsync for fetching single spawner data - Added updateRemoteSpawnerStackSizeAsync for remote stack changes - Added deleteRemoteSpawnerAsync for remote spawner removal - New RemoteAdminStackerHolder for remote stack editing context --- .../commands/list/ListSubCommand.java | 22 +- .../gui/adminstacker/AdminStackerHandler.java | 107 ++++++++- .../list/gui/adminstacker/AdminStackerUI.java | 90 ++++++++ .../RemoteAdminStackerHolder.java | 44 ++++ .../list/gui/list/SpawnerListGUI.java | 39 ++-- .../gui/management/SpawnerManagementGUI.java | 36 ++- .../management/SpawnerManagementHandler.java | 123 +++++++++- .../data/database/SpawnerDatabaseHandler.java | 216 ++++++++++++++++++ 8 files changed, 647 insertions(+), 30 deletions(-) create mode 100644 core/src/main/java/github/nighter/smartspawner/commands/list/gui/adminstacker/RemoteAdminStackerHolder.java 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 ef3030e3..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 @@ -731,6 +731,15 @@ public void openSpawnerManagementGUI(Player player, String spawnerId, String wor * 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; @@ -740,7 +749,7 @@ public void openSpawnerListGUIForServer(Player player, String targetServer, Stri // If it's the current server, use local data if (targetServer.equals(currentServer)) { - openSpawnerListGUI(player, worldName, page); + openSpawnerListGUI(player, worldName, page, filter, sort); return; } @@ -754,7 +763,9 @@ public void openSpawnerListGUIForServer(Player player, String targetServer, Stri player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1.0f, 1.0f); final int requestedPage = page; - dbHandler.getCrossServerSpawnersAsync(targetServer, worldName, spawners -> { + 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; @@ -773,7 +784,7 @@ public void openSpawnerListGUIForServer(Player player, String targetServer, Stri String title = languageManager.getGuiTitle("gui_title_spawner_list", titlePlaceholders); Inventory inv = Bukkit.createInventory( - new SpawnerListHolder(currentPage, totalPages, worldName, FilterOption.ALL, SortOption.DEFAULT, targetServer), + new SpawnerListHolder(currentPage, totalPages, worldName, finalFilter, finalSort, targetServer), 54, title ); @@ -787,12 +798,15 @@ public void openSpawnerListGUIForServer(Player player, String targetServer, Stri inv.addItem(createCrossServerSpawnerItem(spawner, targetServer)); } - // Add navigation buttons (filter/sort disabled for remote) + // 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")); 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 71e7c0dd..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; @@ -119,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 986c9f7f..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 @@ -158,31 +158,40 @@ public void onSpawnerListClick(InventoryClickEvent event) { String targetServer = holder.getTargetServer(); boolean isRemote = holder.isRemoteServer(); - // For remote servers, filter/sort buttons are disabled - if (!isRemote) { - // Handle filter button click - if (event.getSlot() == 48) { - // Cycle to next filter option - FilterOption nextFilter = currentFilter.getNextOption(); - - // Save user preference when they change filter + // 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 (only for local) + if (!isRemote) { listSubCommand.saveUserPreference(player, worldName, nextFilter, currentSort); + } + if (isRemote) { + listSubCommand.openSpawnerListGUIForServer(player, targetServer, worldName, 1, nextFilter, currentSort); + } else { listSubCommand.openSpawnerListGUI(player, worldName, 1, nextFilter, currentSort); - return; } + return; + } - // Handle sort button click - if (event.getSlot() == 50) { - // Cycle to next sort option - SortOption nextSort = currentSort.getNextOption(); + // 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 + // Save user preference when they change sort (only for local) + if (!isRemote) { listSubCommand.saveUserPreference(player, worldName, currentFilter, nextSort); + } + if (isRemote) { + listSubCommand.openSpawnerListGUIForServer(player, targetServer, worldName, 1, currentFilter, nextSort); + } else { listSubCommand.openSpawnerListGUI(player, worldName, 1, currentFilter, nextSort); - return; } + return; } // Handle navigation - works for both local and remote 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 f6ba5996..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 @@ -69,30 +69,34 @@ public void openManagementMenu(Player player, String spawnerId, String worldName ); player.playSound(player.getLocation(), Sound.UI_BUTTON_CLICK, 1.0f, 1.0f); - // Teleport button - disabled for remote servers + // 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 button - disabled for remote servers + // 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) { - createDisabledActionItem(inv, OPEN_SPAWNER_SLOT, "spawner_management.open_spawner", Material.ENDER_EYE, "Remote Server"); + 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 - disabled for remote servers + // Stack button - enabled for both local and remote + // For remote: updates stack size in database if (isRemote) { - createDisabledActionItem(inv, STACK_SLOT, "spawner_management.stack", Material.SPAWNER, "Remote Server"); + createRemoteActionItem(inv, STACK_SLOT, "spawner_management.stack", Material.SPAWNER, "Edit Stack Size"); } else { createActionItem(inv, STACK_SLOT, "spawner_management.stack", Material.SPAWNER); } - // Remove button - disabled for remote servers + // Remove button - enabled for both local and remote + // For remote: removes from database (physical block remains until target server syncs) if (isRemote) { - createDisabledActionItem(inv, REMOVE_SLOT, "spawner_management.remove", Material.BARRIER, "Remote Server"); + createRemoteActionItem(inv, REMOVE_SLOT, "spawner_management.remove", Material.BARRIER, "Remove from DB"); } else { createActionItem(inv, REMOVE_SLOT, "spawner_management.remove", Material.BARRIER); } @@ -147,6 +151,24 @@ private void createDisabledActionItem(Inventory inv, int slot, String langKey, M 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 72daafaf..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,6 +10,7 @@ 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.database.SpawnerDatabaseHandler; import github.nighter.smartspawner.spawner.data.storage.SpawnerStorage; import org.bukkit.Location; import org.bukkit.Material; @@ -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; @@ -63,9 +66,17 @@ public void onSpawnerManagementClick(InventoryClickEvent event) { return; } - // For remote servers, all other actions are disabled + // For remote servers, handle specific actions if (isRemote) { - player.playSound(player.getLocation(), Sound.ENTITY_VILLAGER_NO, 1.0f, 1.0f); + 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; } @@ -162,10 +173,116 @@ private void handleBack(Player player, String worldName, int listPage, String ta } 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/spawner/data/database/SpawnerDatabaseHandler.java b/core/src/main/java/github/nighter/smartspawner/spawner/data/database/SpawnerDatabaseHandler.java index 6008fe53..02bc76c1 100644 --- 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 @@ -888,6 +888,222 @@ public void getSpawnerCountForServerAsync(String targetServer, Consumer }); } + /** + * 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. */