diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/Core.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/Core.java index dc102b6..46ccfa0 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/Core.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/Core.java @@ -50,30 +50,27 @@ public Core(CorePlugin plugin, PremiumVanish premiumVanish) { plugin.getCoreLogger().info("Successfully hooked into SayanVanish!"); } - PlayerDataStore playerDataStore = null; - String playerDataStorageType = config.getPlayerDataStorageType(); + PlayerJoinTracker firstJoinTracker = null; + PlayerDataStore playerDataStore = null; + + StorageType firstJoinType = config.getFirstJoinStorageType(); + StorageType playerDataType = config.getPlayerDataStorageType(); + try { - if ("SQL".equalsIgnoreCase(playerDataStorageType)) { - playerDataStore = new SQLPlayerDataStore( - plugin.getCoreLogger(), - config.buildSqlConfig(), - plugin.getDataFolder().toPath() - ); - plugin.getCoreLogger().info("Using SQL storage for player data."); - } else { - playerDataStore = new H2PlayerDataStore( - plugin.getCoreLogger(), - plugin.getDataFolder().toPath().resolve("player_data").toAbsolutePath().toString() - ); - plugin.getCoreLogger().info("Using H2 storage for player data."); - } - } catch (SQLDriverLoader.DriverLoadException ex) { - plugin.getCoreLogger().severe("Failed to download/load the SQL driver — player data is unavailable. " + - "Check your internet connection or place the driver JAR manually in the plugins/NetworkJoinMessages/drivers/ folder."); - plugin.getCoreLogger().debug("Exception: " + ex); - } catch (Exception ex) { - plugin.getCoreLogger().severe("Failed to load player data handler! Persistent player data will be unavailable."); - plugin.getCoreLogger().debug("Exception: " + ex); + ActiveStorageBackends backends = StorageInitializer.initialize( + firstJoinType, + playerDataType, + config.buildSqlConfig(), + plugin.getDataFolder().toPath(), + plugin.getCoreLogger()); + firstJoinTracker = backends.joinTracker(); + playerDataStore = backends.playerDataStore(); + plugin.getCoreLogger().info("Storage initialized — first-join: " + firstJoinType + + ", player-data: " + playerDataType + "."); + } catch (StorageInitializer.StorageInitializationException e) { + plugin.getCoreLogger().severe("Storage initialisation failed: " + e.getMessage() + + " — first-join tracking and persistent player data will be unavailable."); + plugin.getCoreLogger().debug("Exception: " + e); } // Core data / state @@ -88,46 +85,13 @@ public Core(CorePlugin plugin, PremiumVanish premiumVanish) { MessageHandler messageHandler = new MessageHandler(plugin, config, stateStore, placeholderResolver, receiverResolver); // Player event helpers - SilenceChecker silenceChecker = new SilenceChecker(plugin, config, stateStore, sayanVanishHook, premiumVanish); - LeaveMessageCache leaveMessageCache = new LeaveMessageCache(plugin, config, messageFormatter, placeholderResolver); - LeaveJoinBufferManager leaveJoinBuffer = new LeaveJoinBufferManager(plugin, config); + SilenceChecker silenceChecker = new SilenceChecker(plugin, config, stateStore, sayanVanishHook, premiumVanish); + LeaveMessageCache leaveMessageCache = new LeaveMessageCache(plugin, config, messageFormatter, placeholderResolver); + LeaveJoinBufferManager leaveJoinBuffer = new LeaveJoinBufferManager(plugin, config); // Discord integration - DiscordWebhookBuilder webhookBuilder = new DiscordWebhookBuilder(plugin, configManager.getDiscordConfig()); - DiscordIntegration discordIntegration = new DiscordIntegration(plugin, placeholderResolver, messageFormatter, webhookBuilder, configManager.getDiscordConfig()); - - // First-join tracker — backend selected from config (nullable; callers guard against null) - PlayerJoinTracker firstJoinTracker = null; - String firstJoinStorageType = config.getFirstJoinStorageType(); - try { - if ("TEXT".equalsIgnoreCase(firstJoinStorageType)) { - firstJoinTracker = new TextPlayerJoinTracker( - plugin.getCoreLogger(), - plugin.getDataFolder().toPath().resolve("joined.txt") - ); - plugin.getCoreLogger().info("Using TEXT storage for first-join tracking (joined.txt)."); - } else if ("SQL".equalsIgnoreCase(firstJoinStorageType)) { - firstJoinTracker = new SQLPlayerJoinTracker( - plugin.getCoreLogger(), - config.buildSqlConfig(), - plugin.getDataFolder().toPath() - ); - plugin.getCoreLogger().info("Using SQL storage for first-join tracking."); - } else { - firstJoinTracker = new H2PlayerJoinTracker( - plugin.getCoreLogger(), - plugin.getDataFolder().toPath().resolve("joined").toAbsolutePath().toString() - ); - plugin.getCoreLogger().info("Using H2 storage for first-join tracking."); - } - } catch (SQLDriverLoader.DriverLoadException ex) { - plugin.getCoreLogger().severe("Failed to download/load the SQL driver — first-join tracking is disabled. " + - "Check your internet connection or place the driver JAR manually in the plugins/NetworkJoinMessages/drivers/ folder."); - plugin.getCoreLogger().debug("Exception: " + ex); - } catch (Exception ex) { - plugin.getCoreLogger().severe("Failed to load first-join tracker! First-join messages will be unavailable."); - plugin.getCoreLogger().debug("Exception: " + ex); - } + DiscordWebhookBuilder webhookBuilder = new DiscordWebhookBuilder(plugin, configManager.getDiscordConfig()); + DiscordIntegration discordIntegration = new DiscordIntegration(plugin, placeholderResolver, messageFormatter, webhookBuilder, configManager.getDiscordConfig()); // Spoof SpoofManager spoofManager = new SpoofManager(plugin, config, messageHandler, messageFormatter, placeholderResolver); @@ -150,4 +114,4 @@ public Core(CorePlugin plugin, PremiumVanish premiumVanish) { this.corePremiumVanishListener = premiumVanish == null ? null : new CorePremiumVanishListener(plugin.getCoreLogger(), config, spoofManager); } -} +} \ No newline at end of file diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/commands/CoreImportCommand.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/commands/CoreImportCommand.java index 106c7fe..431eb66 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/commands/CoreImportCommand.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/commands/CoreImportCommand.java @@ -20,7 +20,7 @@ public CoreImportCommand(PlayerJoinTracker playerJoinTracker) { @Override public void execute(CoreCommandSender coreCommandSender, String[] args) { if (playerJoinTracker == null) { - coreCommandSender.sendMessage(Component.text("Import is unavailable: the first-join database failed to initialise on startup.", NamedTextColor.RED)); + coreCommandSender.sendMessage(Component.text("Import is unavailable: the first-join database failed to initialize on startup.", NamedTextColor.RED)); return; } if (args.length < 1) { diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/config/PluginConfig.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/config/PluginConfig.java index 3ef0aca..5567271 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/config/PluginConfig.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/config/PluginConfig.java @@ -7,6 +7,7 @@ import xyz.earthcow.networkjoinmessages.common.ConfigManager; import xyz.earthcow.networkjoinmessages.common.abstraction.CorePlugin; import xyz.earthcow.networkjoinmessages.common.storage.SQLConfig; +import xyz.earthcow.networkjoinmessages.common.storage.StorageType; import java.util.*; import java.util.concurrent.ThreadLocalRandom; @@ -57,8 +58,8 @@ public final class PluginConfig { @Getter private String consoleSilentLeave; // Storage backends - @Getter private String firstJoinStorageType; - @Getter private String playerDataStorageType; + @Getter private StorageType firstJoinStorageType; + @Getter private StorageType playerDataStorageType; // SQL backend config (only used when StorageType is SQL) @Getter private String sqlHost; @@ -177,8 +178,8 @@ public void reload() { leaveCacheDuration = config.getInt("Settings.LeaveNetworkMessageCacheDuration"); leaveJoinBufferDuration = config.getInt("Settings.LeaveJoinBufferDuration"); silentJoinDefaultState = config.getBoolean("Settings.SilentJoinDefaultState"); - firstJoinStorageType = config.getString("Settings.FirstJoinStorageType").toUpperCase(); - playerDataStorageType = config.getString("Settings.PlayerDataStorageType").toUpperCase(); + firstJoinStorageType = config.getEnum("Settings.FirstJoinStorageType", StorageType.class); + playerDataStorageType = config.getEnum("Settings.PlayerDataStorageType", StorageType.class); sqlHost = config.getString("Settings.SQL.Host"); sqlPort = config.getInt("Settings.SQL.Port"); @@ -237,27 +238,21 @@ public void reload() { /** Validates fields with a constrained set of valid values and resets invalid ones. */ private void validateConstrainedFields() { - switch (firstJoinStorageType) { - case "H2", "TEXT", "SQL" -> { /* valid */ } - default -> { - plugin.getCoreLogger().info( - "Setting error: Settings.FirstJoinStorageType only allows H2, TEXT, or SQL. " + + if (firstJoinStorageType == null) { + plugin.getCoreLogger().info( + "Setting error: Settings.FirstJoinStorageType only allows H2, TEXT, or SQL. " + "Got '" + firstJoinStorageType + "'. Defaulting to H2." - ); - firstJoinStorageType = "H2"; - } + ); + firstJoinStorageType = StorageType.H2; } - switch (playerDataStorageType) { - case "H2", "SQL" -> { /* valid */ } - default -> { - plugin.getCoreLogger().info( - "Setting error: Settings.PlayerDataStorageType only allows H2 or SQL. " + - "Got '" + playerDataStorageType + "'. Defaulting to H2." - ); - playerDataStorageType = "H2"; - } + if (playerDataStorageType == null || playerDataStorageType == StorageType.TEXT) { + plugin.getCoreLogger().info( + "Setting error: Settings.PlayerDataStorageType only allows H2 or SQL. " + + "Got '" + playerDataStorageType + "'. Defaulting to H2." + ); + playerDataStorageType = StorageType.H2; } - if ("SQL".equals(firstJoinStorageType)) { + if (firstJoinStorageType == StorageType.SQL || playerDataStorageType == StorageType.SQL) { switch (sqlDriver) { case "mysql", "mariadb", "postgresql" -> { /* valid */ } default -> { @@ -339,8 +334,8 @@ public Collection getCustomCharts() { YamlDocument defaults = Objects.requireNonNull(configManager.getPluginConfig().getDefaults()); charts.add(new SimplePie("leave_cache_duration", () -> String.valueOf(leaveCacheDuration))); - charts.add(new SimplePie("first_join_storage_type", () -> firstJoinStorageType)); - charts.add(new SimplePie("player_data_storage_type", () -> firstJoinStorageType)); + charts.add(new SimplePie("first_join_storage_type", () -> String.valueOf(firstJoinStorageType))); + charts.add(new SimplePie("player_data_storage_type", () -> String.valueOf(firstJoinStorageType))); charts.add(new SimplePie("swap_enabled", () -> String.valueOf(swapServerMessageEnabled))); charts.add(new SimplePie("first_join_enabled", () -> String.valueOf(firstJoinNetworkMessageEnabled))); charts.add(new SimplePie("join_enabled", () -> String.valueOf(joinNetworkMessageEnabled))); diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/ActiveStorageBackends.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/ActiveStorageBackends.java new file mode 100644 index 0000000..62582ed --- /dev/null +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/ActiveStorageBackends.java @@ -0,0 +1,33 @@ +package xyz.earthcow.networkjoinmessages.common.storage; + +/** + * Holds the live {@link PlayerJoinTracker} and {@link PlayerDataStore} instances + * that the plugin should use after a successful call to {@link StorageInitializer#initialize}. + * + *

Both fields are guaranteed non-null when returned by the initializer. The plugin + * is responsible for calling {@link #close()} during shutdown so that any underlying + * connections or file handles are released cleanly. + */ +public record ActiveStorageBackends( + PlayerJoinTracker joinTracker, + PlayerDataStore playerDataStore +) implements AutoCloseable { + + /** + * Closes both backends. Exceptions from each are caught independently so that + * a failure in one does not prevent the other from being closed. + */ + @Override + public void close() { + try { + joinTracker.close(); + } catch (Exception e) { + // Logged by the caller; swallowed here so playerDataStore still closes. + } + try { + playerDataStore.close(); + } catch (Exception e) { + // Logged by the caller. + } + } +} \ No newline at end of file diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/H2PlayerDataStore.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/H2PlayerDataStore.java index a3999a8..f09c877 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/H2PlayerDataStore.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/H2PlayerDataStore.java @@ -5,6 +5,8 @@ import xyz.earthcow.networkjoinmessages.common.util.PlayerDataSnapshot; import java.sql.*; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.UUID; /** @@ -36,6 +38,9 @@ public class H2PlayerDataStore extends H2Handler implements PlayerDataStore { private static final String RESOLVE_SQL = "SELECT player_uuid FROM players WHERE LOWER(player_name) = LOWER(?)"; + private static final String EXPORT_SQL = + "SELECT * FROM players"; + // MERGE upserts the identity columns on first insert, then UPDATE sets all // preference columns so an existing row is fully overwritten on save. private static final String UPSERT_SQL = @@ -107,4 +112,26 @@ public synchronized UUID resolveUuid(String playerName) { } return null; } + + @Override + public synchronized Map exportAll() { + Map result = new LinkedHashMap<>(); + if (isConnectionInvalid()) return result; + try (Statement stmt = connection().createStatement(); + ResultSet rs = stmt.executeQuery(EXPORT_SQL)) { + while (rs.next()) { + UUID uuid = UUID.fromString(rs.getString("player_uuid")); + result.put(uuid, new PlayerDataSnapshot( + rs.getString("player_name"), + rs.getObject("silent_state", Boolean.class), + rs.getObject("ignore_join", Boolean.class), + rs.getObject("ignore_swap", Boolean.class), + rs.getObject("ignore_leave", Boolean.class) + )); + } + } catch (SQLException e) { + logger.severe("[H2PlayerDataStore] SQL failure during exportAll(): " + e.getMessage()); + } + return result; + } } diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/H2PlayerJoinTracker.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/H2PlayerJoinTracker.java index 0e063ae..c8e07ff 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/H2PlayerJoinTracker.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/H2PlayerJoinTracker.java @@ -3,6 +3,8 @@ import xyz.earthcow.networkjoinmessages.common.abstraction.CoreLogger; import java.sql.*; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.UUID; /** @@ -22,6 +24,9 @@ public class H2PlayerJoinTracker extends H2Handler implements PlayerJoinTracker private static final String UPSERT_SQL = "MERGE INTO players_joined (player_uuid, player_name) KEY(player_uuid) VALUES (?, ?)"; + private static final String EXPORT_SQL = + "SELECT player_uuid, player_name FROM players_joined"; + public H2PlayerJoinTracker(CoreLogger logger, String dbPath) throws SQLException { super(logger, dbPath); } @@ -60,4 +65,23 @@ public synchronized void markAsJoined(UUID playerUuid, String playerName) { logger.severe("SQL failure: Could not mark player '" + playerName + "' (" + playerUuid + ") as joined"); } } + + /** + * Exports all first-join records as a UUID -> player name snapshot. + * Used during storage type migration. + */ + @Override + public synchronized Map exportAll() { + Map result = new LinkedHashMap<>(); + if (isConnectionInvalid()) return result; + try (Statement stmt = connection().createStatement(); + ResultSet rs = stmt.executeQuery(EXPORT_SQL)) { + while (rs.next()) { + result.put(UUID.fromString(rs.getString("player_uuid")), rs.getString("player_name")); + } + } catch (SQLException e) { + logger.severe("[H2PlayerJoinTracker] SQL failure during exportAll(): " + e.getMessage()); + } + return result; + } } diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/PlayerDataStore.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/PlayerDataStore.java index 45d2174..486dfef 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/PlayerDataStore.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/PlayerDataStore.java @@ -3,6 +3,7 @@ import org.jetbrains.annotations.Nullable; import xyz.earthcow.networkjoinmessages.common.util.PlayerDataSnapshot; +import java.util.Map; import java.util.UUID; public interface PlayerDataStore extends AutoCloseable { @@ -29,6 +30,8 @@ public interface PlayerDataStore extends AutoCloseable { @Nullable UUID resolveUuid(String playerName); + Map exportAll(); + /** No-op default so callers don't need to handle checked exceptions for backends that don't need closing. */ @Override default void close() throws Exception {} diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/PlayerJoinTracker.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/PlayerJoinTracker.java index fadafa1..7510158 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/PlayerJoinTracker.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/PlayerJoinTracker.java @@ -9,6 +9,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Map; import java.util.UUID; /** @@ -53,6 +54,8 @@ default boolean addUsersFromUserCache(String userCacheStr) { } } + Map exportAll(); + /** No-op default so callers don't need to handle checked exceptions for backends that don't need closing. */ @Override default void close() throws Exception {} diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/SQLHandler.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/SQLHandler.java index e10586d..9c0030c 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/SQLHandler.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/SQLHandler.java @@ -17,13 +17,12 @@ abstract class SQLHandler implements AutoCloseable { private Connection connection; protected SQLHandler(CoreLogger logger, SQLConfig sqlConfig, Path dataFolder) - throws SQLException, SQLDriverLoader.DriverLoadException { + throws SQLDriverLoader.DriverLoadException { this.logger = logger; this.sqlConfig = sqlConfig; this.isPostgres = "postgresql".equals(sqlConfig.driver()); this.logPrefix = "[" + getClass().getSimpleName() + "] "; new SQLDriverLoader(logger, dataFolder).ensureLoaded(sqlConfig.driver()); - setUpConnection(); } protected abstract String createTableSql(); @@ -52,7 +51,7 @@ protected synchronized boolean isConnectionInvalid() { /** * Opens a new connection and ensures the table exists. */ - private void setUpConnection() throws SQLException { + protected void setUpConnection() throws SQLException { String url = buildJdbcUrl(); this.connection = DriverManager.getConnection(url, sqlConfig.username(), sqlConfig.password()); try (Statement stmt = connection.createStatement()) { diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/SQLPlayerDataStore.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/SQLPlayerDataStore.java index 74ce6cc..794167d 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/SQLPlayerDataStore.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/SQLPlayerDataStore.java @@ -7,6 +7,8 @@ import java.nio.file.Path; import java.sql.*; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.UUID; /** @@ -32,6 +34,7 @@ public class SQLPlayerDataStore extends SQLHandler implements PlayerDataStore { private final String RESOLVE_SQL; private final String UPSERT_MYSQL; private final String UPSERT_POSTGRES; + private final String EXPORT_SQL; public SQLPlayerDataStore(CoreLogger logger, SQLConfig sqlConfig, Path dataFolder) throws SQLException, SQLDriverLoader.DriverLoadException { @@ -83,7 +86,11 @@ public SQLPlayerDataStore(CoreLogger logger, SQLConfig sqlConfig, Path dataFolde " ignore_join = EXCLUDED.ignore_join," + " ignore_swap = EXCLUDED.ignore_swap," + " ignore_leave = EXCLUDED.ignore_leave"; + this.EXPORT_SQL = + "SELECT player_uuid, player_name, silent_state, ignore_join, ignore_swap, ignore_leave" + + " FROM " + tableName; + setUpConnection(); } @Override @@ -147,4 +154,26 @@ public synchronized UUID resolveUuid(String playerName) { } return null; } + + @Override + public synchronized Map exportAll() { + Map result = new LinkedHashMap<>(); + if (isConnectionInvalid()) return result; + try (Statement stmt = connection().createStatement(); + ResultSet rs = stmt.executeQuery(EXPORT_SQL)) { + while (rs.next()) { + UUID uuid = UUID.fromString(rs.getString("player_uuid")); + result.put(uuid, new PlayerDataSnapshot( + rs.getString("player_name"), + rs.getObject("silent_state", Boolean.class), + rs.getObject("ignore_join", Boolean.class), + rs.getObject("ignore_swap", Boolean.class), + rs.getObject("ignore_leave", Boolean.class) + )); + } + } catch (SQLException e) { + logger.severe("[SQLPlayerDataStore] SQL failure during exportAll(): " + e.getMessage()); + } + return result; + } } \ No newline at end of file diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/SQLPlayerJoinTracker.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/SQLPlayerJoinTracker.java index fec9be3..cc99f17 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/SQLPlayerJoinTracker.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/SQLPlayerJoinTracker.java @@ -5,6 +5,8 @@ import java.nio.file.Path; import java.sql.*; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.UUID; /** @@ -28,6 +30,7 @@ public class SQLPlayerJoinTracker extends SQLHandler implements PlayerJoinTracke private final String SELECT_SQL; private final String UPSERT_MYSQL; private final String UPSERT_POSTGRES; + private final String EXPORT_SQL; public SQLPlayerJoinTracker(CoreLogger logger, SQLConfig sqlConfig, Path dataFolder) throws SQLException, SQLDriverLoader.DriverLoadException { @@ -55,7 +58,10 @@ public SQLPlayerJoinTracker(CoreLogger logger, SQLConfig sqlConfig, Path dataFol this.UPSERT_POSTGRES = "INSERT INTO " + tableName + " (player_uuid, player_name) VALUES (?, ?) " + "ON CONFLICT (player_uuid) DO UPDATE SET player_name = EXCLUDED.player_name"; + this.EXPORT_SQL = + "SELECT player_uuid, player_name FROM " + tableName; + setUpConnection(); } @Override @@ -89,4 +95,22 @@ public synchronized void markAsJoined(UUID playerUuid, String playerName) { logger.severe("[SQLPlayerJoinTracker] SQL failure marking player '" + playerName + "' (" + playerUuid + ") as joined: " + e.getMessage()); } } + + @Override + public synchronized Map exportAll() { + Map result = new LinkedHashMap<>(); + if (isConnectionInvalid()) return result; + try (Statement stmt = connection().createStatement(); + ResultSet rs = stmt.executeQuery(EXPORT_SQL)) { + while (rs.next()) { + result.put( + UUID.fromString(rs.getString("player_uuid")), + rs.getString("player_name") + ); + } + } catch (SQLException e) { + logger.severe("[SQLPlayerJoinTracker] SQL failure during exportAll(): " + e.getMessage()); + } + return result; + } } diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/StorageInitializer.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/StorageInitializer.java new file mode 100644 index 0000000..cd67080 --- /dev/null +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/StorageInitializer.java @@ -0,0 +1,337 @@ +package xyz.earthcow.networkjoinmessages.common.storage; + +import xyz.earthcow.networkjoinmessages.common.abstraction.CoreLogger; +import xyz.earthcow.networkjoinmessages.common.util.SQLDriverLoader; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.sql.SQLException; + +/** + * Entry point for all storage initialisation at plugin startup. + * + *

Migration detection

+ * Rather than maintaining a separate state file, migration is triggered by the + * presence of leftover data files from a previous backend. + * + *

SQL source limitation

+ * If the configured type is H2 or TEXT but the previous type was SQL, there are + * no local files to detect — the data lives on a remote server. Automatic + * migration in that direction is not supported. Administrators who need to move + * data from SQL to a local backend should use the + * {@code /njimport} command after startup. + * + *

Multiple remnant files

+ * If both {@code joined.mv.db} and {@code joined.txt} exist simultaneously + * (which should not happen under normal operation), the H2 file takes priority + * since it was the default backend and is more likely to be authoritative. + * + *

File archiving

+ * After a successful migration the source files are moved into a + * {@code migrate/} subdirectory. This provides a recoverable backup and + * prevents the same files from triggering a re-migration on the next restart. + * + *

Error handling

+ * Failure to open the source backend skips migration with a warn; + * the new backend starts empty. Failure to open the target backend is + * fatal and propagates as {@link StorageInitializationException}. + */ +public final class StorageInitializer { + + // H2 appends these suffixes to the base path passed to its constructor + private static final String H2_MAIN_SUFFIX = ".mv.db"; + private static final String H2_TRACE_SUFFIX = ".trace.db"; + private static final String TEXT_JOINED_FILE = "joined.txt"; + + // Base names passed to H2 constructors (relative to dataFolder) + private static final String H2_FIRSTJOIN_BASE = "joined"; + private static final String H2_PLAYERDATA_BASE = "player_data"; + + private StorageInitializer() {} + + /** + * Initializes both storage backends, automatically migrating data if + * leftover files from a previous backend are detected. + * + * @param firstJoinType the first-join tracker backend from config + * @param playerDataType the player-data store backend from config (TEXT is rejected) + * @param sqlConfig SQL connection parameters + * @param dataFolder the plugin's data directory + * @param logger plugin logger + * @return the fully initialized, ready-to-use backends + * @throws StorageInitializationException if a target backend cannot be created, + * or if {@code playerDataType} is {@link StorageType#TEXT} + */ + public static ActiveStorageBackends initialize( + StorageType firstJoinType, + StorageType playerDataType, + SQLConfig sqlConfig, + Path dataFolder, + CoreLogger logger) throws StorageInitializationException { + + if (playerDataType == StorageType.TEXT) { + throw new StorageInitializationException( + "PlayerDataStorageType cannot be TEXT — player-data storage does not support " + + "plain-text files. Valid options are H2 and SQL.", null); + } + + PlayerJoinTracker joinTracker = initJoinTracker(firstJoinType, sqlConfig, dataFolder, logger); + PlayerDataStore playerDataStore = initPlayerDataStore(playerDataType, sqlConfig, dataFolder, logger); + + return new ActiveStorageBackends(joinTracker, playerDataStore); + } + + // --- Per-interface initialisation --- + + private static PlayerJoinTracker initJoinTracker( + StorageType configuredType, + SQLConfig sqlConfig, + Path dataFolder, + CoreLogger logger) throws StorageInitializationException { + + PlayerJoinTracker target = buildJoinTracker(configuredType, sqlConfig, dataFolder, logger); + + // Detect what, if any, legacy backend files are sitting alongside us. + boolean h2Remnant = Files.exists(dataFolder.resolve(H2_FIRSTJOIN_BASE + H2_MAIN_SUFFIX)); + boolean textRemnant = Files.exists(dataFolder.resolve(TEXT_JOINED_FILE)); + + if (configuredType == StorageType.H2 && !h2Remnant && !textRemnant) { + // Normal H2 startup — no migration needed regardless. + return target; + } + + // H2 remnant takes priority if both somehow coexist. + if (h2Remnant && configuredType != StorageType.H2) { + logger.info("[StorageInitializer] Detected joined.mv.db alongside " + + configuredType + " — migrating H2 → " + configuredType + "."); + runJoinTrackerMigration(StorageType.H2, sqlConfig, dataFolder, logger, target); + } else if (textRemnant && configuredType != StorageType.TEXT) { + logger.info("[StorageInitializer] Detected joined.txt alongside " + + configuredType + " — migrating TEXT → " + configuredType + "."); + runJoinTrackerMigration(StorageType.TEXT, sqlConfig, dataFolder, logger, target); + } + + return target; + } + + private static PlayerDataStore initPlayerDataStore( + StorageType configuredType, + SQLConfig sqlConfig, + Path dataFolder, + CoreLogger logger) throws StorageInitializationException { + + PlayerDataStore target = buildPlayerDataStore(configuredType, sqlConfig, dataFolder, logger); + + boolean h2Remnant = Files.exists(dataFolder.resolve(H2_PLAYERDATA_BASE + H2_MAIN_SUFFIX)); + + if (h2Remnant && configuredType != StorageType.H2) { + logger.info("[StorageInitializer] Detected player_data.mv.db alongside " + + configuredType + " — migrating H2 → " + configuredType + "."); + runPlayerDataMigration(sqlConfig, dataFolder, logger, target); + } + + return target; + } + + // --- Migration runners --- + + private static void runJoinTrackerMigration( + StorageType sourceType, + SQLConfig sqlConfig, + Path dataFolder, + CoreLogger logger, + PlayerJoinTracker target) { + + PlayerJoinTracker source = null; + try { + source = buildJoinTracker(sourceType, sqlConfig, dataFolder, logger); + } catch (StorageInitializationException e) { + logger.warn("[StorageInitializer] Could not open " + sourceType + + " first-join source for migration: " + e.getMessage()); + logger.warn("[StorageInitializer] First-join migration skipped."); + return; + } + + try { + int count = StorageMigrator.migrateJoinTracker(source, target, logger); + logger.info("[StorageInitializer] First-join migration complete — " + + count + " record(s) transferred."); + archiveJoinTrackerFiles(sourceType, dataFolder, logger); + } finally { + closeSilently(source, logger, "source first-join tracker"); + } + } + + private static void runPlayerDataMigration( + SQLConfig sqlConfig, + Path dataFolder, + CoreLogger logger, + PlayerDataStore target) { + + // The only detectable source for player data is H2 (TEXT is invalid, + // SQL leaves no local files). + PlayerDataStore source = null; + try { + source = buildPlayerDataStore(StorageType.H2, sqlConfig, dataFolder, logger); + } catch (StorageInitializationException e) { + logger.warn("[StorageInitializer] Could not open H2 player-data source for migration: " + + e.getMessage()); + logger.warn("[StorageInitializer] Player-data migration skipped."); + return; + } + + try { + int count = StorageMigrator.migratePlayerDataStore(source, target, logger); + logger.info("[StorageInitializer] Player-data migration complete — " + + count + " record(s) transferred."); + archivePlayerDataFiles(dataFolder, logger); + } finally { + closeSilently(source, logger, "source player-data store"); + } + } + + // --- File archiving --- + + private static void archiveJoinTrackerFiles( + StorageType sourceType, Path dataFolder, CoreLogger logger) { + switch (sourceType) { + case H2 -> { + archiveFile(dataFolder.resolve(H2_FIRSTJOIN_BASE + H2_MAIN_SUFFIX), dataFolder, logger); + archiveFile(dataFolder.resolve(H2_FIRSTJOIN_BASE + H2_TRACE_SUFFIX), dataFolder, logger); + } + case TEXT -> + archiveFile(dataFolder.resolve(TEXT_JOINED_FILE), dataFolder, logger); + default -> + logger.debug("[StorageInitializer] No local files to archive for source type " + sourceType + "."); + } + } + + private static void archivePlayerDataFiles(Path dataFolder, CoreLogger logger) { + archiveFile(dataFolder.resolve(H2_PLAYERDATA_BASE + H2_MAIN_SUFFIX), dataFolder, logger); + archiveFile(dataFolder.resolve(H2_PLAYERDATA_BASE + H2_TRACE_SUFFIX), dataFolder, logger); + } + + /** + * Moves {@code file} into {@code /migrate/}, creating that + * directory if needed. Silently skips files that do not exist (H2 only + * creates the {@code .trace.db} file when debug logging is active). + * Appends a numeric suffix if an archive of the same name already exists. + */ + private static void archiveFile(Path file, Path dataFolder, CoreLogger logger) { + if (!Files.exists(file)) return; + try { + Path archiveDir = dataFolder.resolve("migrate"); + Files.createDirectories(archiveDir); + Path destination = resolveNonConflicting(archiveDir.resolve(file.getFileName())); + Files.move(file, destination, StandardCopyOption.ATOMIC_MOVE); + logger.info("[StorageInitializer] Archived " + file.getFileName() + + " → migrate/" + destination.getFileName()); + } catch (IOException e) { + logger.warn("[StorageInitializer] Could not archive " + file.getFileName() + + ": " + e.getMessage() + " — file remains in place."); + } + } + + /** + * Returns {@code path} unchanged if it does not exist, otherwise appends + * {@code .1}, {@code .2}, … until a free name is found. + */ + private static Path resolveNonConflicting(Path path) { + if (!Files.exists(path)) return path; + String name = path.getFileName().toString(); + Path parent = path.getParent(); + int n = 1; + Path candidate; + do { + candidate = parent.resolve(name + "." + n++); + } while (Files.exists(candidate)); + return candidate; + } + + // --- Backend factories --- + + private static PlayerJoinTracker buildJoinTracker( + StorageType type, + SQLConfig sqlConfig, + Path dataFolder, + CoreLogger logger) throws StorageInitializationException { + return switch (type) { + case H2 -> { + try { + yield new H2PlayerJoinTracker( + logger, + dataFolder.resolve(H2_FIRSTJOIN_BASE).toAbsolutePath().toString() + ); + } catch (SQLException e) { + throw new StorageInitializationException("Failed to open H2PlayerJoinTracker", e); + } + } + case SQL -> { + try { + yield new SQLPlayerJoinTracker(logger, sqlConfig, dataFolder); + } catch (SQLException | SQLDriverLoader.DriverLoadException e) { + throw new StorageInitializationException("Failed to open SQLPlayerJoinTracker", e); + } + } + case TEXT -> { + try { + yield new TextPlayerJoinTracker( + logger, + dataFolder.resolve(TEXT_JOINED_FILE) + ); + } catch (IOException e) { + throw new StorageInitializationException("Failed to open TextPlayerJoinTracker", e); + } + } + }; + } + + private static PlayerDataStore buildPlayerDataStore( + StorageType type, + SQLConfig sqlConfig, + Path dataFolder, + CoreLogger logger) throws StorageInitializationException { + return switch (type) { + case H2 -> { + try { + yield new H2PlayerDataStore( + logger, + dataFolder.resolve(H2_PLAYERDATA_BASE).toAbsolutePath().toString() + ); + } catch (SQLException e) { + throw new StorageInitializationException("Failed to open H2PlayerDataStore", e); + } + } + case SQL -> { + try { + yield new SQLPlayerDataStore(logger, sqlConfig, dataFolder); + } catch (SQLException | SQLDriverLoader.DriverLoadException e) { + throw new StorageInitializationException("Failed to open SQLPlayerDataStore", e); + } + } + case TEXT -> throw new StorageInitializationException( + "TEXT is not a valid PlayerDataStorageType.", null); + }; + } + + private static void closeSilently(AutoCloseable c, CoreLogger logger, String label) { + if (c == null) return; + try { + c.close(); + } catch (Exception e) { + logger.warn("[StorageInitializer] Error closing " + label + ": " + e.getMessage()); + } + } + + /** + * Thrown when a storage backend cannot be created during plugin startup. + * Wraps the underlying cause so callers need only catch one type. + */ + public static final class StorageInitializationException extends Exception { + public StorageInitializationException(String message, Throwable cause) { + super(message, cause); + } + } +} \ No newline at end of file diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/StorageMigrator.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/StorageMigrator.java new file mode 100644 index 0000000..c997188 --- /dev/null +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/StorageMigrator.java @@ -0,0 +1,93 @@ +package xyz.earthcow.networkjoinmessages.common.storage; + +import xyz.earthcow.networkjoinmessages.common.abstraction.CoreLogger; +import xyz.earthcow.networkjoinmessages.common.util.PlayerDataSnapshot; + +import java.util.Map; +import java.util.UUID; + +/** + * Stateless utility that migrates records between storage back-ends. + * + *

Both migration methods follow the same pattern: + *

    + *
  1. Bulk-export all records from {@code source} via its {@code exportAll()} method.
  2. + *
  3. Upsert each record into {@code target} one at a time.
  4. + *
  5. Return the number of records successfully written.
  6. + *
+ * + *

Target implementations use upsert semantics, so calling these methods against a + * partially-populated target is safe — duplicate records are overwritten, not duplicated. + * + *

Neither method closes the source or target; that responsibility belongs to the caller. + */ +public final class StorageMigrator { + + private StorageMigrator() {} + + /** + * Copies every first-join record from {@code source} into {@code target}. + * + * @param source the backend to read from + * @param target the backend to write into + * @param logger used for progress and error messages + * @return the number of records written to {@code target} + */ + public static int migrateJoinTracker( + PlayerJoinTracker source, + PlayerJoinTracker target, + CoreLogger logger) { + + logger.info("[StorageMigrator] Exporting first-join records from " + source.getClass().getSimpleName() + "..."); + Map entries = source.exportAll(); + + if (entries.isEmpty()) { + logger.info("[StorageMigrator] Source contains no first-join records — nothing to migrate."); + return 0; + } + + logger.info("[StorageMigrator] Migrating " + entries.size() + " first-join record(s) into " + + target.getClass().getSimpleName() + "..."); + int count = 0; + for (Map.Entry entry : entries.entrySet()) { + target.markAsJoined(entry.getKey(), entry.getValue()); + count++; + } + + logger.info("[StorageMigrator] First-join migration complete — " + count + " record(s) written."); + return count; + } + + /** + * Copies every player-data record from {@code source} into {@code target}. + * + * @param source the backend to read from + * @param target the backend to write into + * @param logger used for progress and error messages + * @return the number of records written to {@code target} + */ + public static int migratePlayerDataStore( + PlayerDataStore source, + PlayerDataStore target, + CoreLogger logger) { + + logger.info("[StorageMigrator] Exporting player-data records from " + source.getClass().getSimpleName() + "..."); + Map entries = source.exportAll(); + + if (entries.isEmpty()) { + logger.info("[StorageMigrator] Source contains no player-data records — nothing to migrate."); + return 0; + } + + logger.info("[StorageMigrator] Migrating " + entries.size() + " player-data record(s) into " + + target.getClass().getSimpleName() + "..."); + int count = 0; + for (Map.Entry entry : entries.entrySet()) { + target.saveData(entry.getKey(), entry.getValue()); + count++; + } + + logger.info("[StorageMigrator] Player-data migration complete — " + count + " record(s) written."); + return count; + } +} \ No newline at end of file diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/StorageType.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/StorageType.java new file mode 100644 index 0000000..a3857cd --- /dev/null +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/StorageType.java @@ -0,0 +1,9 @@ +package xyz.earthcow.networkjoinmessages.common.storage; + +public enum StorageType { + + H2, + SQL, + TEXT; + +} \ No newline at end of file diff --git a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/TextPlayerJoinTracker.java b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/TextPlayerJoinTracker.java index b0e564c..987083f 100644 --- a/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/TextPlayerJoinTracker.java +++ b/src/main/java/xyz/earthcow/networkjoinmessages/common/storage/TextPlayerJoinTracker.java @@ -7,9 +7,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.util.LinkedHashSet; -import java.util.Set; -import java.util.UUID; +import java.util.*; /** * Tracks which players have ever joined the network using a plain text file. @@ -32,8 +30,11 @@ public class TextPlayerJoinTracker implements PlayerJoinTracker { private final CoreLogger logger; private final Path filePath; - /** In-memory set of every UUID that has ever joined. */ - private final Set joinedUuids = new LinkedHashSet<>(); + /** + * In-memory map of every UUID that has ever joined, keyed by UUID and + * valued by the player name last seen on that line (or {@code ""} + */ + private final Map joinedPlayers = new LinkedHashMap<>(); public TextPlayerJoinTracker(CoreLogger logger, Path filePath) throws IOException { this.logger = logger; @@ -43,12 +44,12 @@ public TextPlayerJoinTracker(CoreLogger logger, Path filePath) throws IOExceptio @Override public synchronized boolean hasJoined(UUID playerUuid) { - return joinedUuids.contains(playerUuid); + return joinedPlayers.containsKey(playerUuid); } @Override public synchronized void markAsJoined(UUID playerUuid, String playerName) { - if (joinedUuids.add(playerUuid)) { + if (joinedPlayers.putIfAbsent(playerUuid, playerName) == null) { appendLine(playerUuid, playerName); } } @@ -77,20 +78,31 @@ private void load() throws IOException { String line = rawLine.trim(); if (line.isEmpty() || line.startsWith("#")) continue; - // If line contains ":" then there must be a player name - // otherwise, the line is simply the UUID + String uuidPart; + String namePart; + if (line.contains(":")) { - line = line.split(":")[0].trim(); + // Splits on first colon + int colon = line.indexOf(':'); + uuidPart = line.substring(0, colon).trim(); + namePart = line.substring(colon + 1).trim(); + } else { + uuidPart = line; + namePart = ""; } try { - joinedUuids.add(UUID.fromString(line)); + UUID uuid = UUID.fromString(uuidPart); + // putIfAbsent preserves the first occurrence when the same UUID + // appears more than once in the file (shouldn't happen, but safe) + joinedPlayers.putIfAbsent(uuid, namePart); } catch (IllegalArgumentException ignored) { - logger.info("[TextPlayerJoinTracker] Skipping unrecognised line in joined.txt: " + rawLine.trim()); + logger.info("[TextPlayerJoinTracker] Skipping unrecognised line in " + + filePath.getFileName() + ": " + rawLine.trim()); } } - logger.debug("[TextPlayerJoinTracker] Loaded " + joinedUuids.size() + " joined-player UUIDs from " + filePath.getFileName()); + logger.debug("[TextPlayerJoinTracker] Loaded " + joinedPlayers.size() + " joined-player UUIDs from " + filePath.getFileName()); } /** @@ -105,4 +117,9 @@ private void appendLine(UUID uuid, String playerName) { logger.severe("[TextPlayerJoinTracker] Failed to persist UUID " + uuid + " (" + playerName + "): " + e.getMessage()); } } + + @Override + public synchronized Map exportAll() { + return Collections.unmodifiableMap(new LinkedHashMap<>(joinedPlayers)); + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index c8ba421..25f3bb8 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -133,7 +133,7 @@ Settings: # SQL - External SQL server (MySQL, MariaDB, or PostgreSQL). Requires filling in the SQL section below. # H2 - Embedded SQL database (default). Best for most servers; no setup required. # TEXT - Plain text file (joined.txt). Easiest to inspect and edit by hand. (Player data will still use H2) - # Changing this option requires a restart. Data is NOT migrated automatically. + # Changing this option requires a restart. Data is migrated automatically except when migrating from SQL. FirstJoinStorageType: H2 # Storage backend for player data