From 02ef9fad1dd1e612c6e649fb3fae0974bc394a5e Mon Sep 17 00:00:00 2001 From: nossr50 Date: Thu, 27 Nov 2025 11:31:35 -0800 Subject: [PATCH] Spears (wip pt 2) --- Changelog.txt | 1 + pom.xml | 109 +- .../nossr50/commands/skills/MacesCommand.java | 4 +- .../commands/skills/SpearsCommand.java | 3 +- .../database/DatabaseManagerFactory.java | 4 +- .../database/FlatFileDatabaseManager.java | 39 +- .../nossr50/database/SQLDatabaseManager.java | 1428 ++++++++------- .../nossr50/datatypes/player/McMMOPlayer.java | 105 +- .../datatypes/player/PlayerProfile.java | 2 +- .../resources/locale/locale_en_US.properties | 22 +- .../database/SQLDatabaseManagerTest.java | 1527 ++++++++++++++--- 11 files changed, 2258 insertions(+), 986 deletions(-) diff --git a/Changelog.txt b/Changelog.txt index cfbb72e0b..0911ef8d3 100644 --- a/Changelog.txt +++ b/Changelog.txt @@ -2,6 +2,7 @@ Version 2.2.046 Added Spears combat skill Added permissions related to Spears Added /spears skill command + Fixed bug where converting from SQL to FlatFile would not copy data for tridents, crossbows, maces, or spears Version 2.2.045 Green Thumb now replants some crops it was failing to replant before (see notes) diff --git a/pom.xml b/pom.xml index 9a631e6f8..e41c2a9de 100644 --- a/pom.xml +++ b/pom.xml @@ -1,4 +1,6 @@ - + 4.0.0 com.gmail.nossr50.mcMMO mcMMO @@ -13,7 +15,7 @@ - + 1.21.10-R0.1-SNAPSHOT 4.23.0 4.4.1-SNAPSHOT @@ -182,11 +184,13 @@ co.aikar.commands - com.gmail.nossr50.mcmmo.acf + com.gmail.nossr50.mcmmo.acf + co.aikar.locales - com.gmail.nossr50.mcmmo.locales + com.gmail.nossr50.mcmmo.locales + org.apache.commons.logging @@ -194,7 +198,8 @@ org.apache.juli - com.gmail.nossr50.mcmmo.database.tomcat.juli + com.gmail.nossr50.mcmmo.database.tomcat.juli + org.apache.tomcat @@ -390,11 +395,11 @@ 3.0.2 compile - - - - - + + + + + org.spigotmc spigot-api @@ -431,10 +436,76 @@ + org.junit.jupiter junit-jupiter - 5.11.0-M2 + 5.11.0 + test + + + + org.testcontainers + testcontainers + 2.0.2 + test + + + + org.testcontainers + testcontainers-junit-jupiter + 2.0.2 + test + + + + + org.apache.logging.log4j + log4j-core + 2.25.2 + test + + + + + org.apache.logging.log4j + log4j-api + 2.25.2 + + + + org.apache.logging.log4j + log4j-slf4j-impl + 2.25.2 + test + + + + org.testcontainers + testcontainers-mysql + test + + + + org.testcontainers + testcontainers-mariadb + test + + + + + com.mysql + mysql-connector-j + 9.5.0 + test + + + + + + org.mariadb.jdbc + mariadb-java-client + 3.5.6 test @@ -452,7 +523,7 @@ org.apache.tomcat tomcat-jdbc - 10.1.24 + 11.0.14 compile @@ -463,7 +534,8 @@ com.google.guava guava - 33.2.0-jre + 33.2.0-jre + compile @@ -473,4 +545,15 @@ compile + + + + org.testcontainers + testcontainers-bom + 2.0.2 + pom + import + + + diff --git a/src/main/java/com/gmail/nossr50/commands/skills/MacesCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/MacesCommand.java index 61b14544e..a24ff8aef 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/MacesCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/MacesCommand.java @@ -23,7 +23,8 @@ public class MacesCommand extends SkillCommand { super(PrimarySkillType.MACES); } - String crippleChanceToApply, crippleChanceToApplyLucky, crippleLengthAgainstPlayers, crippleLengthAgainstMobs; + String crippleChanceToApply, crippleChanceToApplyLucky, crippleLengthAgainstPlayers, + crippleLengthAgainstMobs; @Override protected void dataCalculations(Player player, float skillValue) { @@ -33,7 +34,6 @@ public class MacesCommand extends SkillCommand { MacesManager.getCrippleTickDuration(true) / 20.0D); crippleLengthAgainstMobs = String.valueOf( MacesManager.getCrippleTickDuration(false) / 20.0D); - crippleChanceToApply = mcMMO.p.getAdvancedConfig().getCrippleChanceToApplyOnHit(crippleRank) + "%"; crippleChanceToApplyLucky = String.valueOf( diff --git a/src/main/java/com/gmail/nossr50/commands/skills/SpearsCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/SpearsCommand.java index f6632a904..0070836e9 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/SpearsCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/SpearsCommand.java @@ -2,6 +2,7 @@ package com.gmail.nossr50.commands.skills; import static com.gmail.nossr50.datatypes.skills.SubSkillType.SPEARS_SPEARS_LIMIT_BREAK; +import static com.gmail.nossr50.util.skills.SkillUtils.canUseSubskill; import static com.gmail.nossr50.util.text.TextComponentFactory.appendSubSkillTextComponents; import com.gmail.nossr50.datatypes.skills.PrimarySkillType; @@ -32,7 +33,7 @@ public class SpearsCommand extends SkillCommand { boolean isLucky) { List messages = new ArrayList<>(); - if (SkillUtils.canUseSubskill(player, SPEARS_SPEARS_LIMIT_BREAK)) { + if (canUseSubskill(player, SPEARS_SPEARS_LIMIT_BREAK)) { messages.add(getStatMessage(SPEARS_SPEARS_LIMIT_BREAK, String.valueOf(CombatUtils.getLimitBreakDamageAgainstQuality(player, SPEARS_SPEARS_LIMIT_BREAK, 1000)))); diff --git a/src/main/java/com/gmail/nossr50/database/DatabaseManagerFactory.java b/src/main/java/com/gmail/nossr50/database/DatabaseManagerFactory.java index d75952890..764960e11 100644 --- a/src/main/java/com/gmail/nossr50/database/DatabaseManagerFactory.java +++ b/src/main/java/com/gmail/nossr50/database/DatabaseManagerFactory.java @@ -28,8 +28,8 @@ public class DatabaseManagerFactory { : "Flatfile") + " database"); } - return mcMMO.p.getGeneralConfig().getUseMySQL() ? new SQLDatabaseManager(logger, - MYSQL_DRIVER) + return mcMMO.p.getGeneralConfig().getUseMySQL() + ? new SQLDatabaseManager(logger, MYSQL_DRIVER) : new FlatFileDatabaseManager(userFilePath, logger, purgeTime, startingLevel); } diff --git a/src/main/java/com/gmail/nossr50/database/FlatFileDatabaseManager.java b/src/main/java/com/gmail/nossr50/database/FlatFileDatabaseManager.java index 017a3b554..f6b4b2381 100644 --- a/src/main/java/com/gmail/nossr50/database/FlatFileDatabaseManager.java +++ b/src/main/java/com/gmail/nossr50/database/FlatFileDatabaseManager.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.TreeSet; import java.util.UUID; +import java.util.logging.Level; import java.util.logging.Logger; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; @@ -845,45 +846,47 @@ public final class FlatFileDatabaseManager implements DatabaseManager { } public void convertUsers(DatabaseManager destination) { - BufferedReader in = null; int convertedUsers = 0; long startMillis = System.currentTimeMillis(); synchronized (fileWritingLock) { - try { - // Open the user file - in = new BufferedReader(new FileReader(usersFilePath)); + try (BufferedReader reader = new BufferedReader(new FileReader(usersFilePath))) { String line; - while ((line = in.readLine()) != null) { - if (line.startsWith("#")) { + while ((line = reader.readLine()) != null) { + line = line.trim(); + + // Skip comments and empty lines + if (line.isEmpty() || line.startsWith("#")) { continue; } - String[] character = line.split(":"); + final String[] character = line.split(":"); try { destination.saveUser(loadFromLine(character)); } catch (Exception e) { - e.printStackTrace(); + // Keep the same semantics as before, but log via logger + final String username = (character.length > USERNAME_INDEX) + ? character[USERNAME_INDEX] + : ""; + logger.log( + Level.SEVERE, + "Could not convert user from FlatFile to SQL DB: " + username, + e + ); } + convertedUsers++; Misc.printProgress(convertedUsers, progressInterval, startMillis); } - } catch (Exception e) { - e.printStackTrace(); - } finally { - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore - } - } + } catch (IOException e) { + logger.log(Level.SEVERE, "Failed to convert users from FlatFile to SQL DB", e); } } } + public boolean saveUserUUID(String userName, UUID uuid) { boolean worked = false; diff --git a/src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java b/src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java index 50866edb1..d302e32ad 100644 --- a/src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java +++ b/src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java @@ -14,8 +14,9 @@ import com.gmail.nossr50.runnables.database.UUIDUpdateAsyncTask; import com.gmail.nossr50.util.LogUtils; import com.gmail.nossr50.util.Misc; import com.gmail.nossr50.util.skills.SkillTools; +import java.io.PrintWriter; +import java.io.StringWriter; import java.sql.Connection; -import java.sql.DatabaseMetaData; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.ResultSetMetaData; @@ -29,6 +30,7 @@ import java.util.Locale; import java.util.Map; import java.util.UUID; import java.util.concurrent.locks.ReentrantLock; +import java.util.logging.Level; import java.util.logging.Logger; import org.apache.tomcat.jdbc.pool.DataSource; import org.apache.tomcat.jdbc.pool.PoolProperties; @@ -44,7 +46,6 @@ public final class SQLDatabaseManager implements DatabaseManager { public static final String USER_VARCHAR = "VARCHAR(40)"; public static final int CHILD_SKILLS_SIZE = 2; public static final String LEGACY_DRIVER_PATH = "com.mysql.jdbc.Driver"; - public static final int MAGIC_NUMBER = 44; private final String tablePrefix = mcMMO.p.getGeneralConfig().getMySQLTablePrefix(); private final Map cachedUserIDs = new HashMap<>(); @@ -53,24 +54,16 @@ public final class SQLDatabaseManager implements DatabaseManager { private DataSource loadPool; private DataSource savePool; - private boolean debug = false; - private final ReentrantLock massUpdateLock = new ReentrantLock(); private final String CHARSET_SQL = "utf8mb4"; //This is compliant with UTF-8 while "utf8" is not, confusing but this is how it is. private final Logger logger; - private final boolean h2; SQLDatabaseManager(Logger logger, String driverPath) { - this(logger, driverPath, false); - } - - SQLDatabaseManager(Logger logger, String driverPath, boolean h2) { this.logger = logger; - this.h2 = h2; - String connectionString = getConnectionString(h2); + String connectionString = getConnectionString(); - if (!h2 && mcMMO.p.getGeneralConfig().getMySQLPublicKeyRetrieval()) { + if (mcMMO.p.getGeneralConfig().getMySQLPublicKeyRetrieval()) { connectionString += "&allowPublicKeyRetrieval=true"; } @@ -91,8 +84,6 @@ public final class SQLDatabaseManager implements DatabaseManager { //throw e; // aborts onEnable() Riking if you want to do this, fully implement it. } - debug = mcMMO.p.getGeneralConfig().getMySQLDebug(); - PoolProperties poolProperties = new PoolProperties(); poolProperties.setDriverClassName(driverPath); poolProperties.setUrl(connectionString); @@ -149,11 +140,7 @@ public final class SQLDatabaseManager implements DatabaseManager { } @NotNull - private static String getConnectionString(boolean h2) { - if (h2) { - return "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL"; - } - + private static String getConnectionString() { String connectionString = "jdbc:mysql://" + mcMMO.p.getGeneralConfig().getMySQLServerName() + ":" + mcMMO.p.getGeneralConfig().getMySQLServerPort() + "/" + mcMMO.p.getGeneralConfig().getMySQLDatabaseName(); @@ -189,7 +176,7 @@ public final class SQLDatabaseManager implements DatabaseManager { + "taming = 0 AND mining = 0 AND woodcutting = 0 AND repair = 0 " + "AND unarmed = 0 AND herbalism = 0 AND excavation = 0 AND " + "archery = 0 AND swords = 0 AND axes = 0 AND acrobatics = 0 " - + "AND fishing = 0 AND alchemy = 0 AND crossbows = 0 AND tridents = 0 AND maces = 0;"); + + "AND fishing = 0 AND alchemy = 0 AND crossbows = 0 AND tridents = 0 AND maces = 0 AND spears = 0;"); statement.executeUpdate( "DELETE FROM `" + tablePrefix + "experience` WHERE NOT EXISTS (SELECT * FROM `" @@ -208,7 +195,7 @@ public final class SQLDatabaseManager implements DatabaseManager { + tablePrefix + "skills` `s` WHERE `" + tablePrefix + "users`.`id` = `s`.`user_id`)"); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(statement); tryClose(connection); @@ -241,7 +228,7 @@ public final class SQLDatabaseManager implements DatabaseManager { "WHERE ((UNIX_TIMESTAMP() - lastlogin) > " + mcMMO.p.getPurgeTime() + ")"); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(statement); tryClose(connection); @@ -270,7 +257,7 @@ public final class SQLDatabaseManager implements DatabaseManager { success = statement.executeUpdate() != 0; } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(statement); tryClose(connection); @@ -292,142 +279,228 @@ public final class SQLDatabaseManager implements DatabaseManager { } public boolean saveUser(PlayerProfile profile) { - boolean success = true; - PreparedStatement statement = null; - Connection connection = null; + final String playerName = profile.getPlayerName(); + final UUID uuid = profile.getUniqueId(); - try { - connection = getConnection(PoolIdentifier.SAVE); + try (Connection connection = getConnection(PoolIdentifier.SAVE)) { + // Make the whole save atomic + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); - int id = getUserID(connection, profile.getPlayerName(), profile.getUniqueId()); + try { + int userId = getUserID(connection, playerName, uuid); + if (userId == -1) { + userId = newUser(connection, playerName, uuid); + if (userId == -1) { + logger.severe("Failed to create new account for " + playerName); + connection.rollback(); + return false; + } + } - if (id == -1) { - id = newUser(connection, profile.getPlayerName(), profile.getUniqueId()); - if (id == -1) { - logger.severe("Failed to create new account for " + profile.getPlayerName()); + if (!updateLastLogin(connection, userId, playerName)) { + connection.rollback(); return false; } - } - statement = connection.prepareStatement("UPDATE " + tablePrefix - + "users SET lastlogin = UNIX_TIMESTAMP() WHERE id = ?"); - statement.setInt(1, id); - success &= (statement.executeUpdate() != 0); - statement.close(); - if (!success) { - logger.severe("Failed to update last login for " + profile.getPlayerName()); + if (!updateSkills(connection, userId, profile, playerName)) { + connection.rollback(); + return false; + } + + if (!updateExperience(connection, userId, profile, playerName)) { + connection.rollback(); + return false; + } + + if (!updateCooldowns(connection, userId, profile, playerName)) { + connection.rollback(); + return false; + } + + if (!updateHudSettings(connection, userId, profile, playerName)) { + connection.rollback(); + return false; + } + + connection.commit(); + connection.setAutoCommit(originalAutoCommit); + return true; + } catch (SQLException e) { + connection.rollback(); + logSQLException(e); + return false; + } finally { + // Best-effort restore + try { + connection.setAutoCommit(true); + } catch (SQLException ignored) { + } + } + } catch (SQLException ex) { + logSQLException(ex); + return false; + } + } + + private boolean updateLastLogin(Connection connection, int userId, String playerName) { + String sql = "UPDATE " + tablePrefix + "users SET lastlogin = UNIX_TIMESTAMP() WHERE id = ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setInt(1, userId); + if (stmt.executeUpdate() == 0) { + logger.severe("Failed to update last login for " + playerName); return false; } + return true; + } catch (SQLException ex) { + logSQLException(ex); + return false; + } + } + + private boolean updateSkills(Connection connection, int userId, PlayerProfile profile, String playerName) { + String sql = "UPDATE " + tablePrefix + "skills SET " + + " taming = ?, mining = ?, repair = ?, woodcutting = ?" + + ", unarmed = ?, herbalism = ?, excavation = ?" + + ", archery = ?, swords = ?, axes = ?, acrobatics = ?" + + ", fishing = ?, alchemy = ?, crossbows = ?, tridents = ?, maces = ?, spears = ?, total = ?" + + " WHERE user_id = ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + int i = 1; + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.TAMING)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.MINING)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.REPAIR)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.WOODCUTTING)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.UNARMED)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.HERBALISM)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.EXCAVATION)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.ARCHERY)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.SWORDS)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.AXES)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.ACROBATICS)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.FISHING)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.ALCHEMY)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.CROSSBOWS)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.TRIDENTS)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.MACES)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.SPEARS)); - statement = connection.prepareStatement("UPDATE " + tablePrefix + "skills SET " - + " taming = ?, mining = ?, repair = ?, woodcutting = ?" - + ", unarmed = ?, herbalism = ?, excavation = ?" - + ", archery = ?, swords = ?, axes = ?, acrobatics = ?" - + ", fishing = ?, alchemy = ?, crossbows = ?, tridents = ?, maces = ?, total = ? WHERE user_id = ?"); - statement.setInt(1, profile.getSkillLevel(PrimarySkillType.TAMING)); - statement.setInt(2, profile.getSkillLevel(PrimarySkillType.MINING)); - statement.setInt(3, profile.getSkillLevel(PrimarySkillType.REPAIR)); - statement.setInt(4, profile.getSkillLevel(PrimarySkillType.WOODCUTTING)); - statement.setInt(5, profile.getSkillLevel(PrimarySkillType.UNARMED)); - statement.setInt(6, profile.getSkillLevel(PrimarySkillType.HERBALISM)); - statement.setInt(7, profile.getSkillLevel(PrimarySkillType.EXCAVATION)); - statement.setInt(8, profile.getSkillLevel(PrimarySkillType.ARCHERY)); - statement.setInt(9, profile.getSkillLevel(PrimarySkillType.SWORDS)); - statement.setInt(10, profile.getSkillLevel(PrimarySkillType.AXES)); - statement.setInt(11, profile.getSkillLevel(PrimarySkillType.ACROBATICS)); - statement.setInt(12, profile.getSkillLevel(PrimarySkillType.FISHING)); - statement.setInt(13, profile.getSkillLevel(PrimarySkillType.ALCHEMY)); - statement.setInt(14, profile.getSkillLevel(PrimarySkillType.CROSSBOWS)); - statement.setInt(15, profile.getSkillLevel(PrimarySkillType.TRIDENTS)); - statement.setInt(16, profile.getSkillLevel(PrimarySkillType.MACES)); int total = 0; for (PrimarySkillType primarySkillType : SkillTools.NON_CHILD_SKILLS) { total += profile.getSkillLevel(primarySkillType); } - statement.setInt(17, total); - statement.setInt(18, id); - success &= (statement.executeUpdate() != 0); - statement.close(); - if (!success) { - logger.severe("Failed to update skills for " + profile.getPlayerName()); - return false; - } + stmt.setInt(i++, total); + stmt.setInt(i, userId); - statement = connection.prepareStatement("UPDATE " + tablePrefix + "experience SET " - + " taming = ?, mining = ?, repair = ?, woodcutting = ?" - + ", unarmed = ?, herbalism = ?, excavation = ?" - + ", archery = ?, swords = ?, axes = ?, acrobatics = ?" - + ", fishing = ?, alchemy = ?, crossbows = ?, tridents = ?, maces = ? WHERE user_id = ?"); - statement.setInt(1, profile.getSkillXpLevel(PrimarySkillType.TAMING)); - statement.setInt(2, profile.getSkillXpLevel(PrimarySkillType.MINING)); - statement.setInt(3, profile.getSkillXpLevel(PrimarySkillType.REPAIR)); - statement.setInt(4, profile.getSkillXpLevel(PrimarySkillType.WOODCUTTING)); - statement.setInt(5, profile.getSkillXpLevel(PrimarySkillType.UNARMED)); - statement.setInt(6, profile.getSkillXpLevel(PrimarySkillType.HERBALISM)); - statement.setInt(7, profile.getSkillXpLevel(PrimarySkillType.EXCAVATION)); - statement.setInt(8, profile.getSkillXpLevel(PrimarySkillType.ARCHERY)); - statement.setInt(9, profile.getSkillXpLevel(PrimarySkillType.SWORDS)); - statement.setInt(10, profile.getSkillXpLevel(PrimarySkillType.AXES)); - statement.setInt(11, profile.getSkillXpLevel(PrimarySkillType.ACROBATICS)); - statement.setInt(12, profile.getSkillXpLevel(PrimarySkillType.FISHING)); - statement.setInt(13, profile.getSkillXpLevel(PrimarySkillType.ALCHEMY)); - statement.setInt(14, profile.getSkillXpLevel(PrimarySkillType.CROSSBOWS)); - statement.setInt(15, profile.getSkillXpLevel(PrimarySkillType.TRIDENTS)); - statement.setInt(16, profile.getSkillXpLevel(PrimarySkillType.MACES)); - statement.setInt(17, id); - success &= (statement.executeUpdate() != 0); - statement.close(); - if (!success) { - logger.severe("Failed to update experience for " + profile.getPlayerName()); - return false; - } - - statement = connection.prepareStatement("UPDATE " + tablePrefix + "cooldowns SET " - + " mining = ?, woodcutting = ?, unarmed = ?" - + ", herbalism = ?, excavation = ?, swords = ?" - + ", axes = ?, blast_mining = ?, chimaera_wing = ?, crossbows = ?" - + ", tridents = ?, maces = ? WHERE user_id = ?"); - statement.setLong(1, profile.getAbilityDATS(SuperAbilityType.SUPER_BREAKER)); - statement.setLong(2, profile.getAbilityDATS(SuperAbilityType.TREE_FELLER)); - statement.setLong(3, profile.getAbilityDATS(SuperAbilityType.BERSERK)); - statement.setLong(4, profile.getAbilityDATS(SuperAbilityType.GREEN_TERRA)); - statement.setLong(5, profile.getAbilityDATS(SuperAbilityType.GIGA_DRILL_BREAKER)); - statement.setLong(6, profile.getAbilityDATS(SuperAbilityType.SERRATED_STRIKES)); - statement.setLong(7, profile.getAbilityDATS(SuperAbilityType.SKULL_SPLITTER)); - statement.setLong(8, profile.getAbilityDATS(SuperAbilityType.BLAST_MINING)); - statement.setLong(9, profile.getUniqueData(UniqueDataType.CHIMAERA_WING_DATS)); - statement.setLong(10, profile.getAbilityDATS(SuperAbilityType.SUPER_SHOTGUN)); - statement.setLong(11, profile.getAbilityDATS(SuperAbilityType.TRIDENTS_SUPER_ABILITY)); - statement.setLong(12, profile.getAbilityDATS(SuperAbilityType.MACES_SUPER_ABILITY)); - statement.setInt(13, id); - success = (statement.executeUpdate() != 0); - statement.close(); - if (!success) { - logger.severe("Failed to update cooldowns for " + profile.getPlayerName()); - return false; - } - - statement = connection.prepareStatement("UPDATE " + tablePrefix - + "huds SET mobhealthbar = ?, scoreboardtips = ? WHERE user_id = ?"); - statement.setString(1, MobHealthbarType.HEARTS.name()); - statement.setInt(2, profile.getScoreboardTipsShown()); - statement.setInt(3, id); - success = (statement.executeUpdate() != 0); - statement.close(); - if (!success) { - logger.severe("Failed to update hud settings for " + profile.getPlayerName()); + if (stmt.executeUpdate() == 0) { + logger.severe("Failed to update skills for " + playerName); return false; } + return true; } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(statement); - tryClose(connection); + logSQLException(ex); + return false; } - - return success; } + private boolean updateExperience(Connection connection, int userId, PlayerProfile profile, String playerName) { + String sql = "UPDATE " + tablePrefix + "experience SET " + + " taming = ?, mining = ?, repair = ?, woodcutting = ?" + + ", unarmed = ?, herbalism = ?, excavation = ?" + + ", archery = ?, swords = ?, axes = ?, acrobatics = ?" + + ", fishing = ?, alchemy = ?, crossbows = ?, tridents = ?, maces = ?, spears = ?" + + " WHERE user_id = ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + int i = 1; + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.TAMING)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.MINING)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.REPAIR)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.WOODCUTTING)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.UNARMED)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.HERBALISM)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.EXCAVATION)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.ARCHERY)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.SWORDS)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.AXES)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.ACROBATICS)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.FISHING)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.ALCHEMY)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.CROSSBOWS)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.TRIDENTS)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.MACES)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.SPEARS)); + stmt.setInt(i, userId); + + if (stmt.executeUpdate() == 0) { + logger.severe("Failed to update experience for " + playerName); + return false; + } + return true; + } catch (SQLException ex) { + logSQLException(ex); + return false; + } + } + + private boolean updateCooldowns(Connection connection, int userId, PlayerProfile profile, String playerName) { + String sql = "UPDATE " + tablePrefix + "cooldowns SET " + + " mining = ?, woodcutting = ?, unarmed = ?" + + ", herbalism = ?, excavation = ?, swords = ?" + + ", axes = ?, blast_mining = ?, chimaera_wing = ?, crossbows = ?" + + ", tridents = ?, maces = ?, spears = ?" + + " WHERE user_id = ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + int i = 1; + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.SUPER_BREAKER)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.TREE_FELLER)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.BERSERK)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.GREEN_TERRA)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.GIGA_DRILL_BREAKER)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.SERRATED_STRIKES)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.SKULL_SPLITTER)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.BLAST_MINING)); + stmt.setLong(i++, profile.getUniqueData(UniqueDataType.CHIMAERA_WING_DATS)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.SUPER_SHOTGUN)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.TRIDENTS_SUPER_ABILITY)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.MACES_SUPER_ABILITY)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.SPEARS_SUPER_ABILITY)); + stmt.setInt(i, userId); + + if (stmt.executeUpdate() == 0) { + logger.severe("Failed to update cooldowns for " + playerName); + return false; + } + return true; + } catch (SQLException ex) { + logSQLException(ex); + return false; + } + } + + private boolean updateHudSettings(Connection connection, int userId, PlayerProfile profile, String playerName) { + String sql = "UPDATE " + tablePrefix + "huds SET mobhealthbar = ?, scoreboardtips = ? WHERE user_id = ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setString(1, MobHealthbarType.HEARTS.name()); + stmt.setInt(2, profile.getScoreboardTipsShown()); + stmt.setInt(3, userId); + + if (stmt.executeUpdate() == 0) { + logger.severe("Failed to update hud settings for " + playerName); + return false; + } + return true; + } catch (SQLException ex) { + logSQLException(ex); + return false; + } + } + + public @NotNull List readLeaderboard(@Nullable PrimarySkillType skill, int pageNumber, int statsPerPage) throws InvalidSkillException { List stats = new ArrayList<>(); @@ -466,7 +539,7 @@ public final class SQLDatabaseManager implements DatabaseManager { stats.add(new PlayerStat(column.get(1), Integer.parseInt(column.get(0)))); } } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(resultSet); tryClose(statement); @@ -569,7 +642,7 @@ public final class SQLDatabaseManager implements DatabaseManager { resultSet.close(); statement.close(); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(resultSet); tryClose(statement); @@ -586,7 +659,7 @@ public final class SQLDatabaseManager implements DatabaseManager { connection = getConnection(PoolIdentifier.MISC); newUser(connection, playerName, uuid); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(connection); } @@ -608,57 +681,75 @@ public final class SQLDatabaseManager implements DatabaseManager { return loadPlayerProfile(player); } } catch (SQLException e) { - e.printStackTrace(); + logger.log(Level.SEVERE, "Unexpected SQLException while creating new user for " + + player.getName(), e); } return new PlayerProfile(player.getName(), player.getUniqueId(), false, mcMMO.p.getAdvancedConfig().getStartingLevel()); } - private int newUser(Connection connection, String playerName, UUID uuid) { - ResultSet resultSet = null; - PreparedStatement statement = null; + private static final String INVALID_OLD_USERNAME = "_INVALID_OLD_USERNAME_"; - try { - statement = connection.prepareStatement( - "UPDATE `" + tablePrefix + "users` " - + "SET `user` = ? " - + "WHERE `user` = ?"); - statement.setString(1, "_INVALID_OLD_USERNAME_"); - statement.setString(2, playerName); - statement.executeUpdate(); - statement.close(); - - statement = connection.prepareStatement("INSERT INTO " + tablePrefix - + "users (user, uuid, lastlogin) VALUES (?, ?, UNIX_TIMESTAMP())", - Statement.RETURN_GENERATED_KEYS); - statement.setString(1, playerName); - statement.setString(2, uuid != null ? uuid.toString() : null); - statement.executeUpdate(); - - resultSet = statement.getGeneratedKeys(); - - if (!resultSet.next()) { - logger.severe("Unable to create new user account in DB"); - return -1; - } - - writeMissingRows(connection, resultSet.getInt(1)); - return resultSet.getInt(1); - } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(resultSet); - tryClose(statement); + private int newUser(Connection connection, String playerName, @Nullable UUID uuid) { + if (connection == null) { + throw new IllegalArgumentException("connection must not be null"); + } + if (playerName == null || playerName.isEmpty()) { + logger.severe("Attempted to create user with null/empty playerName"); + return -1; + } + + // Step 1: Invalidate any existing rows with the same username + String invalidateSql = + "UPDATE `" + tablePrefix + "users` " + + "SET `user` = ? " + + "WHERE `user` = ?"; + + try (PreparedStatement invalidateStmt = connection.prepareStatement(invalidateSql)) { + invalidateStmt.setString(1, INVALID_OLD_USERNAME); + invalidateStmt.setString(2, playerName); + invalidateStmt.executeUpdate(); + } catch (SQLException ex) { + logSQLException(ex); + return -1; + } + + // Step 2: Insert the new user and fetch the generated id + String insertSql = + "INSERT INTO " + tablePrefix + + "users (user, uuid, lastlogin) VALUES (?, ?, UNIX_TIMESTAMP())"; + + try (PreparedStatement insertStmt = connection.prepareStatement( + insertSql, Statement.RETURN_GENERATED_KEYS)) { + + insertStmt.setString(1, playerName); + insertStmt.setString(2, uuid != null ? uuid.toString() : null); + insertStmt.executeUpdate(); + + try (ResultSet keys = insertStmt.getGeneratedKeys()) { + if (!keys.next()) { + logger.severe("Unable to create new user account in DB for player '" + playerName + "'"); + return -1; + } + + int userId = keys.getInt(1); + writeMissingRows(connection, userId); + return userId; + } + } catch (SQLException ex) { + logSQLException(ex); + return -1; } - return -1; } + public @NotNull PlayerProfile loadPlayerProfile(@NotNull String playerName) { try { return loadPlayerFromDB(null, playerName); } catch (RuntimeException e) { - e.printStackTrace(); + mcMMO.p.getLogger().log(Level.SEVERE, + "Unexpected error while loading player profile for " + playerName, e); return new PlayerProfile(playerName, false, mcMMO.p.getAdvancedConfig().getStartingLevel()); } @@ -680,142 +771,181 @@ public final class SQLDatabaseManager implements DatabaseManager { } private PlayerProfile loadPlayerFromDB(@Nullable UUID uuid, @Nullable String playerName) - throws RuntimeException { + throws IllegalArgumentException { if (uuid == null && playerName == null) { - throw new RuntimeException( + throw new IllegalArgumentException( "Error looking up player, both UUID and playerName are null and one must not be."); } - PreparedStatement statement = null; - Connection connection = null; - ResultSet resultSet = null; - - try { - connection = getConnection(PoolIdentifier.LOAD); + try (Connection connection = getConnection(PoolIdentifier.LOAD)) { int id = getUserID(connection, playerName, uuid); if (id == -1) { - // There is no such user - return new PlayerProfile(playerName, - mcMMO.p.getAdvancedConfig().getStartingLevel()); + return createEmptyProfile(playerName); } - // There is such a user + writeMissingRows(connection, id); - statement = connection.prepareStatement( + String sql = "SELECT " + - "s.taming, s.mining, s.repair, s.woodcutting, s.unarmed, s.herbalism, s.excavation, s.archery, s.swords, s.axes, s.acrobatics, s.fishing, s.alchemy, s.crossbows, s.tridents, s.maces, " - + - "e.taming, e.mining, e.repair, e.woodcutting, e.unarmed, e.herbalism, e.excavation, e.archery, e.swords, e.axes, e.acrobatics, e.fishing, e.alchemy, e.crossbows, e.tridents, e.maces, " - + - "c.taming, c.mining, c.repair, c.woodcutting, c.unarmed, c.herbalism, c.excavation, c.archery, c.swords, c.axes, c.acrobatics, c.blast_mining, c.chimaera_wing, c.crossbows, c.tridents, c.maces, " - + - "h.mobhealthbar, h.scoreboardtips, u.uuid, u.`user` " - + "FROM " + tablePrefix + "users u " - + "JOIN " + tablePrefix + "skills s ON (u.id = s.user_id) " - + "JOIN " + tablePrefix + "experience e ON (u.id = e.user_id) " - + "JOIN " + tablePrefix + "cooldowns c ON (u.id = c.user_id) " - + "JOIN " + tablePrefix + "huds h ON (u.id = h.user_id) " - + "WHERE u.id = ?" - ); - statement.setInt(1, id); + // --- skills (levels) --- + "s.taming AS skill_taming, " + + "s.mining AS skill_mining, " + + "s.repair AS skill_repair, " + + "s.woodcutting AS skill_woodcutting, " + + "s.unarmed AS skill_unarmed, " + + "s.herbalism AS skill_herbalism, " + + "s.excavation AS skill_excavation, " + + "s.archery AS skill_archery, " + + "s.swords AS skill_swords, " + + "s.axes AS skill_axes, " + + "s.acrobatics AS skill_acrobatics, " + + "s.fishing AS skill_fishing, " + + "s.alchemy AS skill_alchemy, " + + "s.crossbows AS skill_crossbows, " + + "s.tridents AS skill_tridents, " + + "s.maces AS skill_maces, " + + "s.spears AS skill_spears, " + - resultSet = statement.executeQuery(); + // --- skills XP --- + "e.taming AS xp_taming, " + + "e.mining AS xp_mining, " + + "e.repair AS xp_repair, " + + "e.woodcutting AS xp_woodcutting, " + + "e.unarmed AS xp_unarmed, " + + "e.herbalism AS xp_herbalism, " + + "e.excavation AS xp_excavation, " + + "e.archery AS xp_archery, " + + "e.swords AS xp_swords, " + + "e.axes AS xp_axes, " + + "e.acrobatics AS xp_acrobatics, " + + "e.fishing AS xp_fishing, " + + "e.alchemy AS xp_alchemy, " + + "e.crossbows AS xp_crossbows, " + + "e.tridents AS xp_tridents, " + + "e.maces AS xp_maces, " + + "e.spears AS xp_spears, " + - if (resultSet.next()) { - try { - PlayerProfile profile = loadFromResult(playerName, resultSet); - String name = resultSet.getString( - MAGIC_NUMBER); // TODO: Magic Number, make sure it stays updated - resultSet.close(); - statement.close(); + // --- cooldowns / unique data --- + // c.taming is unused for abilities, left out or alias if you want it + "c.mining AS cd_super_breaker, " + + "c.repair AS cd_repair_unused, " + // unused but explicit + "c.woodcutting AS cd_tree_feller, " + + "c.unarmed AS cd_berserk, " + + "c.herbalism AS cd_green_terra, " + + "c.excavation AS cd_giga_drill_breaker, " + + "c.archery AS cd_explosive_shot, " + + "c.swords AS cd_serrated_strikes, " + + "c.axes AS cd_skull_splitter, " + + "c.acrobatics AS cd_acrobatics_unused, " + // unused but explicit + "c.blast_mining AS cd_blast_mining, " + + "c.chimaera_wing AS ud_chimaera_wing_dats, " + + "c.crossbows AS cd_super_shotgun, " + + "c.tridents AS cd_tridents_super_ability, " + + "c.maces AS cd_maces_super_ability, " + + "c.spears AS cd_spears_super_ability, " + - if (playerName != null - && !playerName.isEmpty() - && !playerName.equalsIgnoreCase(name) - && uuid != null) { - statement = connection.prepareStatement( - "UPDATE `" + tablePrefix + "users` " - + "SET `user` = ? " - + "WHERE `user` = ?"); - statement.setString(1, "_INVALID_OLD_USERNAME_"); - statement.setString(2, name); - statement.executeUpdate(); - statement.close(); - statement = connection.prepareStatement( - "UPDATE `" + tablePrefix + "users` " - + "SET `user` = ?, uuid = ? " - + "WHERE id = ?"); - statement.setString(1, playerName); - statement.setString(2, uuid.toString()); - statement.setInt(3, id); - statement.executeUpdate(); - statement.close(); + // --- HUD + user info --- + "h.mobhealthbar AS mobhealthbar, " + + "h.scoreboardtips AS scoreboardtips, " + + "u.uuid AS uuid, " + + "u.`user` AS username " + + "FROM " + tablePrefix + "users u " + + "JOIN " + tablePrefix + "skills s ON (u.id = s.user_id) " + + "JOIN " + tablePrefix + "experience e ON (u.id = e.user_id) " + + "JOIN " + tablePrefix + "cooldowns c ON (u.id = c.user_id) " + + "JOIN " + tablePrefix + "huds h ON (u.id = h.user_id) " + + "WHERE u.id = ?"; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, id); + + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return createEmptyProfile(playerName); } - return profile; - } catch (SQLException e) { - printErrors(e); + String nameInDb = resultSet.getString("username"); + + if (shouldUpdateUsername(playerName, uuid, nameInDb)) { + invalidateOldUsername(connection, nameInDb); + updateCurrentUsername(connection, id, playerName, uuid); + } + + if (playerName == null || playerName.isEmpty()) { + playerName = nameInDb; + } + + return loadFromResult(playerName, resultSet); } } - resultSet.close(); } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(resultSet); - tryClose(statement); - tryClose(connection); + logSQLException(ex); + return createEmptyProfile(playerName); } + } - //Return empty profile + + private PlayerProfile createEmptyProfile(@Nullable String playerName) { return new PlayerProfile(playerName, mcMMO.p.getAdvancedConfig().getStartingLevel()); } - public void convertUsers(DatabaseManager destination) { - PreparedStatement statement = null; - Connection connection = null; - ResultSet resultSet = null; + private boolean shouldUpdateUsername(@Nullable String playerName, @Nullable UUID uuid, String nameInDb) { + return playerName != null + && !playerName.isEmpty() + && !playerName.equalsIgnoreCase(nameInDb) + && uuid != null; + } + + private void invalidateOldUsername(Connection connection, String oldName) throws SQLException { + String sql = "UPDATE `" + tablePrefix + "users` SET `user` = ? WHERE `user` = ?"; + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setString(1, "_INVALID_OLD_USERNAME_"); + stmt.setString(2, oldName); + stmt.executeUpdate(); + } + } + + private void updateCurrentUsername(Connection connection, int id, String playerName, UUID uuid) throws SQLException { + String sql = "UPDATE `" + tablePrefix + "users` SET `user` = ?, uuid = ? WHERE id = ?"; + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setString(1, playerName); + stmt.setString(2, uuid.toString()); + stmt.setInt(3, id); + stmt.executeUpdate(); + } + } + + + public void convertUsers(DatabaseManager destination) { + // Get the list of usernames we want to migrate + final List usernames = getStoredUsers(); + if (usernames.isEmpty()) { + logger.info("No stored users found to convert."); + return; + } - try { - connection = getConnection(PoolIdentifier.MISC); - statement = connection.prepareStatement( - "SELECT " - + "s.taming, s.mining, s.repair, s.woodcutting, s.unarmed, s.herbalism, s.excavation, s.archery, s.swords, s.axes, s.acrobatics, s.fishing, s.alchemy, " - + "e.taming, e.mining, e.repair, e.woodcutting, e.unarmed, e.herbalism, e.excavation, e.archery, e.swords, e.axes, e.acrobatics, e.fishing, e.alchemy, " - + "c.taming, c.mining, c.repair, c.woodcutting, c.unarmed, c.herbalism, c.excavation, c.archery, c.swords, c.axes, c.acrobatics, c.blast_mining, c.chimaera_wing, " - + "h.mobhealthbar, h.scoreboardtips, u.uuid " - + "FROM " + tablePrefix + "users u " - + "JOIN " + tablePrefix + "skills s ON (u.id = s.user_id) " - + "JOIN " + tablePrefix + "experience e ON (u.id = e.user_id) " - + "JOIN " + tablePrefix + "cooldowns c ON (u.id = c.user_id) " - + "JOIN " + tablePrefix + "huds h ON (u.id = h.user_id) " - + "WHERE u.`user` = ?"); - List usernames = getStoredUsers(); int convertedUsers = 0; long startMillis = System.currentTimeMillis(); + for (String playerName : usernames) { - statement.setString(1, playerName); try { - resultSet = statement.executeQuery(); - resultSet.next(); - destination.saveUser(loadFromResult(playerName, resultSet)); - resultSet.close(); - } catch (SQLException e) { - printErrors(e); - // Ignore + // Reuse the canonical loading path (handles schema, aliases, etc.) + final PlayerProfile profile = loadPlayerProfile(playerName); + + // Delegate save to the destination database manager + destination.saveUser(profile); + } catch (Exception ex) { + // Log and continue with remaining users + logger.log(Level.SEVERE, "Failed to convert user '" + playerName + "'", ex); } + convertedUsers++; Misc.printProgress(convertedUsers, progressInterval, startMillis); } - } catch (SQLException e) { - printErrors(e); - } finally { - tryClose(resultSet); - tryClose(statement); - tryClose(connection); - } - } + logger.info("Finished converting " + convertedUsers + " users."); + } public boolean saveUserUUID(String userName, UUID uuid) { PreparedStatement statement = null; @@ -831,7 +961,7 @@ public final class SQLDatabaseManager implements DatabaseManager { statement.execute(); return true; } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); return false; } finally { tryClose(statement); @@ -870,7 +1000,7 @@ public final class SQLDatabaseManager implements DatabaseManager { return true; } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); return false; } finally { tryClose(statement); @@ -893,7 +1023,7 @@ public final class SQLDatabaseManager implements DatabaseManager { users.add(resultSet.getString("user")); } } catch (SQLException e) { - printErrors(e); + logSQLException(e); } finally { tryClose(resultSet); tryClose(statement); @@ -906,273 +1036,309 @@ public final class SQLDatabaseManager implements DatabaseManager { /** * Checks that the database structure is present and correct */ + /** + * Checks that the database structure is present and correct. + * Runs once on startup. + */ private void checkStructure() { - PreparedStatement statement = null; - Statement createStatement = null; - ResultSet resultSet = null; - Connection connection = null; + try (Connection connection = getConnection(PoolIdentifier.MISC)) { + final String schemaQuery = + "SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = ?" + + " AND table_name = ?"; - try { - connection = getConnection(PoolIdentifier.MISC); - String schemaQuery = this.h2 - ? "SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_name = ?" - : "SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = ? AND table_name = ?"; - - statement = connection.prepareStatement(schemaQuery); - - setStatementQuery(statement, "users"); - - resultSet = statement.executeQuery(); - - if (!resultSet.next()) { - createStatement = connection.createStatement(); - String sql = "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "users` (" + - "`id` int AUTO_INCREMENT," + - "`user` varchar(40) NOT NULL," + - "`uuid` varchar(36)," + - "`lastlogin` bigint NOT NULL," + - "PRIMARY KEY (`id`)," + - "INDEX `user_index`(`user`)," + - "UNIQUE(`uuid`))"; - createStatement.executeUpdate(sql); - tryClose(createStatement); + try (PreparedStatement schemaStmt = connection.prepareStatement(schemaQuery)) { + ensureUsersTable(connection, schemaStmt); + ensureHudsTable(connection, schemaStmt); + ensureCooldownsTable(connection, schemaStmt); + ensureSkillsTable(connection, schemaStmt); + ensureExperienceTable(connection, schemaStmt); } - tryClose(resultSet); - setStatementQuery(statement, "huds"); - resultSet = statement.executeQuery(); - if (!resultSet.next()) { - createStatement = connection.createStatement(); - createStatement.executeUpdate( - "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "huds` (" - + "`user_id` int(10) unsigned NOT NULL," - + "`mobhealthbar` varchar(50) NOT NULL DEFAULT '" - + mcMMO.p.getGeneralConfig().getMobHealthbarDefault() + "'," - + "`scoreboardtips` int(10) NOT NULL DEFAULT '0'," - + "PRIMARY KEY (`user_id`)) " - + "DEFAULT CHARSET=" + CHARSET_SQL + ";"); - tryClose(createStatement); - } - tryClose(resultSet); - setStatementQuery(statement, "cooldowns"); - resultSet = statement.executeQuery(); - if (!resultSet.next()) { - createStatement = connection.createStatement(); - createStatement.executeUpdate( - "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "cooldowns` (" - + "`user_id` int(10) unsigned NOT NULL," - + "`taming` int(32) unsigned NOT NULL DEFAULT '0'," - + "`mining` int(32) unsigned NOT NULL DEFAULT '0'," - + "`woodcutting` int(32) unsigned NOT NULL DEFAULT '0'," - + "`repair` int(32) unsigned NOT NULL DEFAULT '0'," - + "`unarmed` int(32) unsigned NOT NULL DEFAULT '0'," - + "`herbalism` int(32) unsigned NOT NULL DEFAULT '0'," - + "`excavation` int(32) unsigned NOT NULL DEFAULT '0'," - + "`archery` int(32) unsigned NOT NULL DEFAULT '0'," - + "`swords` int(32) unsigned NOT NULL DEFAULT '0'," - + "`axes` int(32) unsigned NOT NULL DEFAULT '0'," - + "`acrobatics` int(32) unsigned NOT NULL DEFAULT '0'," - + "`blast_mining` int(32) unsigned NOT NULL DEFAULT '0'," - + "`chimaera_wing` int(32) unsigned NOT NULL DEFAULT '0'," - + "`crossbows` int(32) unsigned NOT NULL DEFAULT '0'," - + "`tridents` int(32) unsigned NOT NULL DEFAULT '0'," - + "`maces` int(32) unsigned NOT NULL DEFAULT '0'," - + "PRIMARY KEY (`user_id`)) " - + "DEFAULT CHARSET=" + CHARSET_SQL + ";"); - tryClose(createStatement); - } - tryClose(resultSet); - setStatementQuery(statement, "skills"); - resultSet = statement.executeQuery(); - if (!resultSet.next()) { - String startingLevel = "'" + mcMMO.p.getAdvancedConfig().getStartingLevel() + "'"; - String totalLevel = "'" + (mcMMO.p.getAdvancedConfig().getStartingLevel() * ( - PrimarySkillType.values().length - CHILD_SKILLS_SIZE)) + "'"; - createStatement = connection.createStatement(); - createStatement.executeUpdate( - "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "skills` (" - + "`user_id` int(10) unsigned NOT NULL," - + "`taming` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`mining` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`woodcutting` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`repair` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`unarmed` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`herbalism` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`excavation` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`archery` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`swords` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`axes` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," - + "`acrobatics` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`fishing` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`alchemy` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`crossbows` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`tridents` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`maces` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," - + "`total` int(10) unsigned NOT NULL DEFAULT " + totalLevel + "," - + "PRIMARY KEY (`user_id`)) " - + "DEFAULT CHARSET=" + CHARSET_SQL + ";"); - tryClose(createStatement); - } - tryClose(resultSet); - setStatementQuery(statement, "experience"); - resultSet = statement.executeQuery(); - if (!resultSet.next()) { - createStatement = connection.createStatement(); - createStatement.executeUpdate( - "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "experience` (" - + "`user_id` int(10) unsigned NOT NULL," - + "`taming` int(10) unsigned NOT NULL DEFAULT '0'," - + "`mining` int(10) unsigned NOT NULL DEFAULT '0'," - + "`woodcutting` int(10) unsigned NOT NULL DEFAULT '0'," - + "`repair` int(10) unsigned NOT NULL DEFAULT '0'," - + "`unarmed` int(10) unsigned NOT NULL DEFAULT '0'," - + "`herbalism` int(10) unsigned NOT NULL DEFAULT '0'," - + "`excavation` int(10) unsigned NOT NULL DEFAULT '0'," - + "`archery` int(10) unsigned NOT NULL DEFAULT '0'," - + "`swords` int(10) unsigned NOT NULL DEFAULT '0'," - + "`axes` int(10) unsigned NOT NULL DEFAULT '0'," - + "`acrobatics` int(10) unsigned NOT NULL DEFAULT '0'," - + "`fishing` int(10) unsigned NOT NULL DEFAULT '0'," - + "`alchemy` int(10) unsigned NOT NULL DEFAULT '0'," - + "`crossbows` int(10) unsigned NOT NULL DEFAULT '0'," - + "`tridents` int(10) unsigned NOT NULL DEFAULT '0'," - + "PRIMARY KEY (`user_id`)) " - + "DEFAULT CHARSET=" + CHARSET_SQL + ";"); - tryClose(createStatement); - } - tryClose(resultSet); - tryClose(statement); + // Run upgrade steps for (UpgradeType updateType : UpgradeType.values()) { checkDatabaseStructure(connection, updateType); } + // Optionally truncate skills to level caps if (mcMMO.p.getGeneralConfig().getTruncateSkills()) { - for (PrimarySkillType skill : SkillTools.NON_CHILD_SKILLS) { - int cap = mcMMO.p.getSkillTools().getLevelCap(skill); - if (cap != Integer.MAX_VALUE) { - statement = connection.prepareStatement( - "UPDATE `" + tablePrefix + "skills` SET `" + skill.name() - .toLowerCase(Locale.ENGLISH) + "` = " + cap + " WHERE `" - + skill.name().toLowerCase(Locale.ENGLISH) + "` > " + cap); - statement.executeUpdate(); - tryClose(statement); - } - } + truncateSkillsToCaps(connection); } - // TODO: refactor - LogUtils.debug(logger, "Killing orphans"); - createStatement = connection.createStatement(); - createStatement.executeUpdate( - "DELETE FROM `" + tablePrefix + "experience` WHERE NOT EXISTS (SELECT * FROM `" - + tablePrefix + "users` `u` WHERE `" + tablePrefix - + "experience`.`user_id` = `u`.`id`)"); - createStatement.executeUpdate( - "DELETE FROM `" + tablePrefix + "huds` WHERE NOT EXISTS (SELECT * FROM `" - + tablePrefix + "users` `u` WHERE `" + tablePrefix - + "huds`.`user_id` = `u`.`id`)"); - createStatement.executeUpdate( - "DELETE FROM `" + tablePrefix + "cooldowns` WHERE NOT EXISTS (SELECT * FROM `" - + tablePrefix + "users` `u` WHERE `" + tablePrefix - + "cooldowns`.`user_id` = `u`.`id`)"); - createStatement.executeUpdate( - "DELETE FROM `" + tablePrefix + "skills` WHERE NOT EXISTS (SELECT * FROM `" - + tablePrefix + "users` `u` WHERE `" + tablePrefix - + "skills`.`user_id` = `u`.`id`)"); + // Clean up orphan rows + deleteOrphans(connection); + } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(resultSet); - tryClose(statement); - tryClose(createStatement); - tryClose(connection); + logSQLException(ex); } + // Column-level structure updates (adds newer skill columns if missing) final String skills = "skills"; + final String experience = "experience"; + final String cooldowns = "cooldowns"; final String crossbows = "crossbows"; final String tridents = "tridents"; final String maces = "maces"; - final String experience = "experience"; - final String cooldowns = "cooldowns"; + final String spears = "spears"; - updateStructure(skills, crossbows, String.valueOf(32)); - updateStructure(skills, tridents, String.valueOf(32)); - updateStructure(skills, maces, String.valueOf(32)); + updateStructure(skills, crossbows, "32"); + updateStructure(skills, tridents, "32"); + updateStructure(skills, maces, "32"); + updateStructure(skills, spears, "32"); - updateStructure(experience, crossbows, String.valueOf(10)); - updateStructure(experience, tridents, String.valueOf(10)); - updateStructure(experience, maces, String.valueOf(10)); + updateStructure(experience, crossbows, "10"); + updateStructure(experience, tridents, "10"); + updateStructure(experience, maces, "10"); + updateStructure(experience, spears, "10"); - updateStructure(cooldowns, crossbows, String.valueOf(10)); - updateStructure(cooldowns, tridents, String.valueOf(10)); - updateStructure(cooldowns, maces, String.valueOf(10)); + updateStructure(cooldowns, crossbows, "10"); + updateStructure(cooldowns, tridents, "10"); + updateStructure(cooldowns, maces, "10"); + updateStructure(cooldowns, spears, "10"); } + private void ensureUsersTable(Connection connection, PreparedStatement schemaStmt) throws SQLException { + if (tableExists(schemaStmt, "users")) { + return; + } + + String sql = "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "users` (" + + "`id` int AUTO_INCREMENT," + + "`user` varchar(40) NOT NULL," + + "`uuid` varchar(36)," + + "`lastlogin` bigint NOT NULL," + + "PRIMARY KEY (`id`)," + + "INDEX `user_index`(`user`)," + + "UNIQUE(`uuid`))"; + + try (Statement createStatement = connection.createStatement()) { + createStatement.executeUpdate(sql); + } + } + + private void ensureHudsTable(Connection connection, PreparedStatement schemaStmt) throws SQLException { + if (tableExists(schemaStmt, "huds")) { + return; + } + + String sql = "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "huds` (" + + "`user_id` int(10) unsigned NOT NULL," + + "`mobhealthbar` varchar(50) NOT NULL DEFAULT '" + + mcMMO.p.getGeneralConfig().getMobHealthbarDefault() + "'," + + "`scoreboardtips` int(10) NOT NULL DEFAULT '0'," + + "PRIMARY KEY (`user_id`)) " + + "DEFAULT CHARSET=" + CHARSET_SQL + ";"; + + try (Statement createStatement = connection.createStatement()) { + createStatement.executeUpdate(sql); + } + } + + private void ensureCooldownsTable(Connection connection, PreparedStatement schemaStmt) throws SQLException { + if (tableExists(schemaStmt, "cooldowns")) { + return; + } + + String sql = "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "cooldowns` (" + + "`user_id` int(10) unsigned NOT NULL," + + "`taming` int(32) unsigned NOT NULL DEFAULT '0'," + + "`mining` int(32) unsigned NOT NULL DEFAULT '0'," + + "`woodcutting` int(32) unsigned NOT NULL DEFAULT '0'," + + "`repair` int(32) unsigned NOT NULL DEFAULT '0'," + + "`unarmed` int(32) unsigned NOT NULL DEFAULT '0'," + + "`herbalism` int(32) unsigned NOT NULL DEFAULT '0'," + + "`excavation` int(32) unsigned NOT NULL DEFAULT '0'," + + "`archery` int(32) unsigned NOT NULL DEFAULT '0'," + + "`swords` int(32) unsigned NOT NULL DEFAULT '0'," + + "`axes` int(32) unsigned NOT NULL DEFAULT '0'," + + "`acrobatics` int(32) unsigned NOT NULL DEFAULT '0'," + + "`blast_mining` int(32) unsigned NOT NULL DEFAULT '0'," + + "`chimaera_wing` int(32) unsigned NOT NULL DEFAULT '0'," + + "`crossbows` int(32) unsigned NOT NULL DEFAULT '0'," + + "`tridents` int(32) unsigned NOT NULL DEFAULT '0'," + + "`maces` int(32) unsigned NOT NULL DEFAULT '0'," + + "`spears` int(32) unsigned NOT NULL DEFAULT '0'," + + "PRIMARY KEY (`user_id`)) " + + "DEFAULT CHARSET=" + CHARSET_SQL + ";"; + + try (Statement createStatement = connection.createStatement()) { + createStatement.executeUpdate(sql); + } + } + + private void ensureSkillsTable(Connection connection, PreparedStatement schemaStmt) throws SQLException { + if (tableExists(schemaStmt, "skills")) { + return; + } + + int starting = mcMMO.p.getAdvancedConfig().getStartingLevel(); + String startingLevel = "'" + starting + "'"; + String totalLevel = "'" + (starting * (PrimarySkillType.values().length - CHILD_SKILLS_SIZE)) + "'"; + + String sql = "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "skills` (" + + "`user_id` int(10) unsigned NOT NULL," + + "`taming` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`mining` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`woodcutting` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`repair` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`unarmed` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`herbalism` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`excavation` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`archery` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`swords` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`axes` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`acrobatics` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`fishing` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`alchemy` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`crossbows` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`tridents` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`maces` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`spears` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`total` int(10) unsigned NOT NULL DEFAULT " + totalLevel + "," + + "PRIMARY KEY (`user_id`)) " + + "DEFAULT CHARSET=" + CHARSET_SQL + ";"; + + try (Statement createStatement = connection.createStatement()) { + createStatement.executeUpdate(sql); + } + } + + private void ensureExperienceTable(Connection connection, PreparedStatement schemaStmt) throws SQLException { + if (tableExists(schemaStmt, "experience")) { + return; + } + + String sql = "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "experience` (" + + "`user_id` int(10) unsigned NOT NULL," + + "`taming` int(10) unsigned NOT NULL DEFAULT '0'," + + "`mining` int(10) unsigned NOT NULL DEFAULT '0'," + + "`woodcutting` int(10) unsigned NOT NULL DEFAULT '0'," + + "`repair` int(10) unsigned NOT NULL DEFAULT '0'," + + "`unarmed` int(10) unsigned NOT NULL DEFAULT '0'," + + "`herbalism` int(10) unsigned NOT NULL DEFAULT '0'," + + "`excavation` int(10) unsigned NOT NULL DEFAULT '0'," + + "`archery` int(10) unsigned NOT NULL DEFAULT '0'," + + "`swords` int(10) unsigned NOT NULL DEFAULT '0'," + + "`axes` int(10) unsigned NOT NULL DEFAULT '0'," + + "`acrobatics` int(10) unsigned NOT NULL DEFAULT '0'," + + "`fishing` int(10) unsigned NOT NULL DEFAULT '0'," + + "`alchemy` int(10) unsigned NOT NULL DEFAULT '0'," + + "`crossbows` int(10) unsigned NOT NULL DEFAULT '0'," + + "`tridents` int(10) unsigned NOT NULL DEFAULT '0'," + + "`maces` int(10) unsigned NOT NULL DEFAULT '0'," + + "`spears` int(10) unsigned NOT NULL DEFAULT '0'," + + "PRIMARY KEY (`user_id`)) " + + "DEFAULT CHARSET=" + CHARSET_SQL + ";"; + + try (Statement createStatement = connection.createStatement()) { + createStatement.executeUpdate(sql); + } + } + + /** + * Uses the shared schema PreparedStatement + existing setStatementQuery logic + * to determine if a given logical table exists. + */ + private boolean tableExists(PreparedStatement schemaStmt, String tableName) throws SQLException { + setStatementQuery(schemaStmt, tableName); + try (ResultSet rs = schemaStmt.executeQuery()) { + return rs.next(); + } + } + + /* -------------------- Post-creation maintenance -------------------- */ + + private void truncateSkillsToCaps(Connection connection) throws SQLException { + for (PrimarySkillType skill : SkillTools.NON_CHILD_SKILLS) { + int cap = mcMMO.p.getSkillTools().getLevelCap(skill); + if (cap == Integer.MAX_VALUE) { + continue; + } + + String column = skill.name().toLowerCase(Locale.ENGLISH); + String sql = "UPDATE `" + tablePrefix + "skills` " + + "SET `" + column + "` = " + cap + " " + + "WHERE `" + column + "` > " + cap; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.executeUpdate(); + } + } + } + + private void deleteOrphans(Connection connection) throws SQLException { + LogUtils.debug(logger, "Killing orphans"); + + try (Statement stmt = connection.createStatement()) { + stmt.executeUpdate( + "DELETE FROM `" + tablePrefix + "experience` " + + "WHERE NOT EXISTS (SELECT * FROM `" + tablePrefix + "users` `u` " + + "WHERE `" + tablePrefix + "experience`.`user_id` = `u`.`id`)"); + + stmt.executeUpdate( + "DELETE FROM `" + tablePrefix + "huds` " + + "WHERE NOT EXISTS (SELECT * FROM `" + tablePrefix + "users` `u` " + + "WHERE `" + tablePrefix + "huds`.`user_id` = `u`.`id`)"); + + stmt.executeUpdate( + "DELETE FROM `" + tablePrefix + "cooldowns` " + + "WHERE NOT EXISTS (SELECT * FROM `" + tablePrefix + "users` `u` " + + "WHERE `" + tablePrefix + "cooldowns`.`user_id` = `u`.`id`)"); + + stmt.executeUpdate( + "DELETE FROM `" + tablePrefix + "skills` " + + "WHERE NOT EXISTS (SELECT * FROM `" + tablePrefix + "users` `u` " + + "WHERE `" + tablePrefix + "skills`.`user_id` = `u`.`id`)"); + } + } + + /* -------------------- Existing helpers (lightly cleaned) -------------------- */ + private void updateStructure(String tableName, String columnName, String columnSize) { try (Connection connection = getConnection(PoolIdentifier.MISC)) { - if (!columnExists(connection, mcMMO.p.getGeneralConfig().getMySQLDatabaseName(), - tablePrefix + tableName, columnName)) { + if (!columnExists(connection, + mcMMO.p.getGeneralConfig().getMySQLDatabaseName(), + tablePrefix + tableName, + columnName)) { + try (Statement createStatement = connection.createStatement()) { - // logger.info("[SQLDB Check] Adding column '" + columnName + "' to table '" + tablePrefix + tableName + "'..."); - String startingLevel = - "'" + mcMMO.p.getAdvancedConfig().getStartingLevel() + "'"; - createStatement.executeUpdate("ALTER TABLE `" + tablePrefix + tableName + "` " - + "ADD COLUMN `" + columnName + "` int(" + columnSize - + ") unsigned NOT NULL DEFAULT " + startingLevel); + String startingLevel = "'" + mcMMO.p.getAdvancedConfig().getStartingLevel() + "'"; + String sql = "ALTER TABLE `" + tablePrefix + tableName + "` " + + "ADD COLUMN `" + columnName + "` int(" + columnSize + ") " + + "unsigned NOT NULL DEFAULT " + startingLevel; + createStatement.executeUpdate(sql); } - } else { - // logger.info("[SQLDB Check] Column '" + columnName + "' already exists in table '" + tablePrefix + tableName + "', looks good!"); } } catch (SQLException e) { - e.printStackTrace(); // Consider more robust logging + logSQLException(e); throw new RuntimeException(e); } } private boolean columnExists(Connection connection, String database, String tableName, String columnName) throws SQLException { - // logger.info("[SQLDB Check] Checking if column '" + columnName + "' exists in table '" + tableName + "'"); - try (Statement createStatement = connection.createStatement()) { - String sql = "SELECT `COLUMN_NAME`\n" + - "FROM `INFORMATION_SCHEMA`.`COLUMNS`\n" + - "WHERE `TABLE_SCHEMA`='" + database + "'\n" + - " AND `TABLE_NAME`='" + tableName + "'\n" + - " AND `COLUMN_NAME`='" + columnName + "'"; - var resultSet = createStatement.executeQuery(sql); - return resultSet.next(); + String sql = "SELECT `COLUMN_NAME` " + + "FROM `INFORMATION_SCHEMA`.`COLUMNS` " + + "WHERE `TABLE_SCHEMA`='" + database + "' " + + "AND `TABLE_NAME`='" + tableName + "' " + + "AND `COLUMN_NAME`='" + columnName + "'"; + + try (Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + + return rs.next(); } catch (SQLException e) { - logger.info("Failed to check if column exists in table " + tableName + " for column " - + columnName); - e.printStackTrace(); + logger.info("Failed to check if column exists in table " + tableName + + " for column " + columnName); + logSQLException(e); throw e; } } - private void setStatementQuery(PreparedStatement statement, String tableName) throws SQLException { - if (!this.h2) { - // Set schema name for MySQL - statement.setString(1, mcMMO.p.getGeneralConfig().getMySQLDatabaseName()); - statement.setString(2, tablePrefix + tableName); - } else { - // For H2, the schema parameter is not needed - statement.setString(1, tablePrefix + tableName); - } + // Set schema name for MySQL + statement.setString(1, mcMMO.p.getGeneralConfig().getMySQLDatabaseName()); + statement.setString(2, tablePrefix + tableName); } Connection getConnection(PoolIdentifier identifier) throws SQLException { @@ -1263,7 +1429,7 @@ public final class SQLDatabaseManager implements DatabaseManager { } } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(statement); } @@ -1299,123 +1465,131 @@ public final class SQLDatabaseManager implements DatabaseManager { statement.execute(); statement.close(); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(statement); } } + private static final PrimarySkillType[] PERSISTED_SKILLS = { + PrimarySkillType.TAMING, + PrimarySkillType.MINING, + PrimarySkillType.REPAIR, + PrimarySkillType.WOODCUTTING, + PrimarySkillType.UNARMED, + PrimarySkillType.HERBALISM, + PrimarySkillType.EXCAVATION, + PrimarySkillType.ARCHERY, + PrimarySkillType.SWORDS, + PrimarySkillType.AXES, + PrimarySkillType.ACROBATICS, + PrimarySkillType.FISHING, + PrimarySkillType.ALCHEMY, + PrimarySkillType.CROSSBOWS, + PrimarySkillType.TRIDENTS, + PrimarySkillType.MACES, + PrimarySkillType.SPEARS + }; + private PlayerProfile loadFromResult(String playerName, ResultSet result) throws SQLException { - final Map skills = new EnumMap<>( - PrimarySkillType.class); // Skill & Level - final Map skillsXp = new EnumMap<>( - PrimarySkillType.class); // Skill & XP - final Map skillsDATS = new EnumMap<>( - SuperAbilityType.class); // Ability & Cooldown - final Map uniqueData = new EnumMap<>( - UniqueDataType.class); //Chimaera wing cooldown and other misc info - UUID uuid; + final var skills = new EnumMap(PrimarySkillType.class); + final var skillsXp = new EnumMap(PrimarySkillType.class); + final var skillsDATS = new EnumMap(SuperAbilityType.class); + final var uniqueData = new EnumMap(UniqueDataType.class); + + // --- Skills & XP by predictable alias name --- + + for (PrimarySkillType skill : PERSISTED_SKILLS) { + String base = skill.name().toLowerCase(Locale.ROOT); // e.g. "taming", "woodcutting" + + int level = result.getInt("skill_" + base); + float xp = result.getFloat("xp_" + base); + + skills.put(skill, level); + skillsXp.put(skill, xp); + } + + // --- Cooldowns / DATS --- + + skillsDATS.put(SuperAbilityType.SUPER_BREAKER, + result.getInt("cd_super_breaker")); + // cd_repair_unused exists but is not mapped to an ability + skillsDATS.put(SuperAbilityType.TREE_FELLER, + result.getInt("cd_tree_feller")); + skillsDATS.put(SuperAbilityType.BERSERK, + result.getInt("cd_berserk")); + skillsDATS.put(SuperAbilityType.GREEN_TERRA, + result.getInt("cd_green_terra")); + skillsDATS.put(SuperAbilityType.GIGA_DRILL_BREAKER, + result.getInt("cd_giga_drill_breaker")); + skillsDATS.put(SuperAbilityType.EXPLOSIVE_SHOT, + result.getInt("cd_explosive_shot")); + skillsDATS.put(SuperAbilityType.SERRATED_STRIKES, + result.getInt("cd_serrated_strikes")); + skillsDATS.put(SuperAbilityType.SKULL_SPLITTER, + result.getInt("cd_skull_splitter")); + // cd_acrobatics_unused exists but not mapped + skillsDATS.put(SuperAbilityType.BLAST_MINING, + result.getInt("cd_blast_mining")); + + uniqueData.put(UniqueDataType.CHIMAERA_WING_DATS, + result.getInt("ud_chimaera_wing_dats")); + + skillsDATS.put(SuperAbilityType.SUPER_SHOTGUN, + result.getInt("cd_super_shotgun")); + skillsDATS.put(SuperAbilityType.TRIDENTS_SUPER_ABILITY, + result.getInt("cd_tridents_super_ability")); + skillsDATS.put(SuperAbilityType.MACES_SUPER_ABILITY, + result.getInt("cd_maces_super_ability")); + skillsDATS.put(SuperAbilityType.SPEARS_SUPER_ABILITY, + result.getInt("cd_spears_super_ability")); + + // --- HUD + UUID --- + int scoreboardTipsShown; - - final int SKILL_COLUMNS = 16; - final int OFFSET_SKILLS = 0; - final int OFFSET_XP = SKILL_COLUMNS; - final int OFFSET_DATS = OFFSET_XP + SKILL_COLUMNS; - final int OFFSET_OTHER = OFFSET_DATS + SKILL_COLUMNS; - - skills.put(PrimarySkillType.TAMING, result.getInt(OFFSET_SKILLS + 1)); - skills.put(PrimarySkillType.MINING, result.getInt(OFFSET_SKILLS + 2)); - skills.put(PrimarySkillType.REPAIR, result.getInt(OFFSET_SKILLS + 3)); - skills.put(PrimarySkillType.WOODCUTTING, result.getInt(OFFSET_SKILLS + 4)); - skills.put(PrimarySkillType.UNARMED, result.getInt(OFFSET_SKILLS + 5)); - skills.put(PrimarySkillType.HERBALISM, result.getInt(OFFSET_SKILLS + 6)); - skills.put(PrimarySkillType.EXCAVATION, result.getInt(OFFSET_SKILLS + 7)); - skills.put(PrimarySkillType.ARCHERY, result.getInt(OFFSET_SKILLS + 8)); - skills.put(PrimarySkillType.SWORDS, result.getInt(OFFSET_SKILLS + 9)); - skills.put(PrimarySkillType.AXES, result.getInt(OFFSET_SKILLS + 10)); - skills.put(PrimarySkillType.ACROBATICS, result.getInt(OFFSET_SKILLS + 11)); - skills.put(PrimarySkillType.FISHING, result.getInt(OFFSET_SKILLS + 12)); - skills.put(PrimarySkillType.ALCHEMY, result.getInt(OFFSET_SKILLS + 13)); - skills.put(PrimarySkillType.CROSSBOWS, result.getInt(OFFSET_SKILLS + 14)); - skills.put(PrimarySkillType.TRIDENTS, result.getInt(OFFSET_SKILLS + 15)); - skills.put(PrimarySkillType.MACES, result.getInt(OFFSET_SKILLS + 16)); - - skillsXp.put(PrimarySkillType.TAMING, result.getFloat(OFFSET_XP + 1)); - skillsXp.put(PrimarySkillType.MINING, result.getFloat(OFFSET_XP + 2)); - skillsXp.put(PrimarySkillType.REPAIR, result.getFloat(OFFSET_XP + 3)); - skillsXp.put(PrimarySkillType.WOODCUTTING, result.getFloat(OFFSET_XP + 4)); - skillsXp.put(PrimarySkillType.UNARMED, result.getFloat(OFFSET_XP + 5)); - skillsXp.put(PrimarySkillType.HERBALISM, result.getFloat(OFFSET_XP + 6)); - skillsXp.put(PrimarySkillType.EXCAVATION, result.getFloat(OFFSET_XP + 7)); - skillsXp.put(PrimarySkillType.ARCHERY, result.getFloat(OFFSET_XP + 8)); - skillsXp.put(PrimarySkillType.SWORDS, result.getFloat(OFFSET_XP + 9)); - skillsXp.put(PrimarySkillType.AXES, result.getFloat(OFFSET_XP + 10)); - skillsXp.put(PrimarySkillType.ACROBATICS, result.getFloat(OFFSET_XP + 11)); - skillsXp.put(PrimarySkillType.FISHING, result.getFloat(OFFSET_XP + 12)); - skillsXp.put(PrimarySkillType.ALCHEMY, result.getFloat(OFFSET_XP + 13)); - skillsXp.put(PrimarySkillType.CROSSBOWS, result.getFloat(OFFSET_XP + 14)); - skillsXp.put(PrimarySkillType.TRIDENTS, result.getFloat(OFFSET_XP + 15)); - skillsXp.put(PrimarySkillType.MACES, result.getFloat(OFFSET_XP + 16)); - - // Taming - Unused - result.getInt(OFFSET_DATS + 1) - skillsDATS.put(SuperAbilityType.SUPER_BREAKER, result.getInt(OFFSET_DATS + 2)); - // Repair - Unused - result.getInt(OFFSET_DATS + 3) - skillsDATS.put(SuperAbilityType.TREE_FELLER, result.getInt(OFFSET_DATS + 4)); - skillsDATS.put(SuperAbilityType.BERSERK, result.getInt(OFFSET_DATS + 5)); - skillsDATS.put(SuperAbilityType.GREEN_TERRA, result.getInt(OFFSET_DATS + 6)); - skillsDATS.put(SuperAbilityType.GIGA_DRILL_BREAKER, result.getInt(OFFSET_DATS + 7)); - skillsDATS.put(SuperAbilityType.EXPLOSIVE_SHOT, result.getInt(OFFSET_DATS + 8)); - // Archery - Unused - result.getInt(OFFSET_DATS + 8) - skillsDATS.put(SuperAbilityType.SERRATED_STRIKES, result.getInt(OFFSET_DATS + 9)); - skillsDATS.put(SuperAbilityType.SKULL_SPLITTER, result.getInt(OFFSET_DATS + 10)); - // Acrobatics - Unused - result.getInt(OFFSET_DATS + 11) - skillsDATS.put(SuperAbilityType.BLAST_MINING, result.getInt(OFFSET_DATS + 12)); - uniqueData.put(UniqueDataType.CHIMAERA_WING_DATS, result.getInt(OFFSET_DATS + 13)); - skillsDATS.put(SuperAbilityType.SUPER_SHOTGUN, result.getInt(OFFSET_DATS + 14)); - skillsDATS.put(SuperAbilityType.TRIDENTS_SUPER_ABILITY, result.getInt(OFFSET_DATS + 15)); - skillsDATS.put(SuperAbilityType.MACES_SUPER_ABILITY, result.getInt(OFFSET_DATS + 16)); - - // ORDER IS AS FOLLOWS - // MOB HEALTH BAR - // SCOREBOARD TIPS - // UUID - // USER - try { - // Mob Health bar is unused, so we add two - // TODO: Why even SELECT the mob health bar? - // Refactor later. - scoreboardTipsShown = result.getInt(OFFSET_OTHER + 2); - } catch (Exception e) { + // For older schemas this may not exist; keep your defensive behavior. + scoreboardTipsShown = result.getInt("scoreboardtips"); + } catch (SQLException | RuntimeException ignored) { scoreboardTipsShown = 0; } + UUID uuid = null; try { - uuid = UUID.fromString(result.getString(OFFSET_OTHER + 3)); - } catch (Exception e) { - uuid = null; + String uuidString = result.getString("uuid"); + if (uuidString != null && !uuidString.isEmpty()) { + uuid = UUID.fromString(uuidString); + } + } catch (SQLException | IllegalArgumentException ignored) { + // Keep uuid as null } return new PlayerProfile(playerName, uuid, skills, skillsXp, skillsDATS, scoreboardTipsShown, uniqueData, null); } - private void printErrors(SQLException ex) { - ex.printStackTrace(); - // logger.severe("SQLException: " + ex.getMessage()); - logger.severe("SQLState: " + ex.getSQLState()); - logger.severe("VendorError: " + ex.getErrorCode()); + private void logSQLException(SQLException ex) { + SQLException current = ex; - // Handling SQLException chain - SQLException nextException = ex.getNextException(); - while (nextException != null) { - logger.severe("Caused by: " + nextException.getMessage()); - nextException = nextException.getNextException(); + while (current != null) { + logger.severe("SQLException occurred:"); + logger.severe(" Message: " + current.getMessage()); + logger.severe(" SQLState: " + current.getSQLState()); + logger.severe(" VendorCode: " + current.getErrorCode()); + + // Log the full stack trace + StringWriter sw = new StringWriter(); + current.printStackTrace(new PrintWriter(sw)); + logger.severe(sw.toString()); + + current = current.getNextException(); + if (current != null) { + logger.severe("Caused by next SQLException in chain:"); + } } } - public DatabaseType getDatabaseType() { return DatabaseType.SQL; } @@ -1437,7 +1611,7 @@ public final class SQLDatabaseManager implements DatabaseManager { + "ADD INDEX `user` (`user`(20) ASC)"); mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.DROP_NAME_UNIQUENESS); } catch (SQLException ex) { - ex.printStackTrace(); + logSQLException(ex); } finally { tryClose(resultSet); } @@ -1543,7 +1717,7 @@ public final class SQLDatabaseManager implements DatabaseManager { mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.ADD_SQL_INDEXES); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(resultSet); } @@ -1578,7 +1752,7 @@ public final class SQLDatabaseManager implements DatabaseManager { mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.ADD_UUIDS); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(resultSet); } @@ -1602,7 +1776,7 @@ public final class SQLDatabaseManager implements DatabaseManager { names.add(resultSet.getString("user")); } } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(resultSet); tryClose(statement); @@ -1644,7 +1818,7 @@ public final class SQLDatabaseManager implements DatabaseManager { mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.DROP_SQL_PARTY_NAMES); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(resultSet); } @@ -1682,7 +1856,7 @@ public final class SQLDatabaseManager implements DatabaseManager { mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.ADD_SKILL_TOTAL); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { connection.setAutoCommit(true); tryClose(resultSet); @@ -1714,7 +1888,7 @@ public final class SQLDatabaseManager implements DatabaseManager { mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.DROP_SPOUT); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(resultSet); } @@ -1747,7 +1921,7 @@ public final class SQLDatabaseManager implements DatabaseManager { return id; } } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(resultSet); tryClose(statement); @@ -1771,7 +1945,7 @@ public final class SQLDatabaseManager implements DatabaseManager { return resultSet.getInt("id"); } } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(resultSet); tryClose(statement); @@ -1815,7 +1989,7 @@ public final class SQLDatabaseManager implements DatabaseManager { statement.setString(1, mcMMO.p.getGeneralConfig().getMobHealthbarDefault().toString()); statement.executeUpdate(); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(statement); tryClose(connection); @@ -1851,7 +2025,7 @@ public final class SQLDatabaseManager implements DatabaseManager { mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.SQL_CHARSET_UTF8MB4); } catch (SQLException e) { - e.printStackTrace(); + logSQLException(e); } } @@ -1884,28 +2058,4 @@ public final class SQLDatabaseManager implements DatabaseManager { " CHARACTER SET utf8mb4\n" + " COLLATE utf8mb4_unicode_ci;"; } - - public void printAllTablesWithColumns(Connection connection) { - try { - DatabaseMetaData metaData = connection.getMetaData(); - String[] types = {"TABLE"}; - ResultSet tables = metaData.getTables(null, null, "%", types); - - while (tables.next()) { - String tableName = tables.getString("TABLE_NAME"); - System.out.println("Table: " + tableName); - - ResultSet columns = metaData.getColumns(null, null, tableName, "%"); - while (columns.next()) { - String columnName = columns.getString("COLUMN_NAME"); - String columnType = columns.getString("TYPE_NAME"); - System.out.println(" Column: " + columnName + " Type: " + columnType); - } - columns.close(); - } - tables.close(); - } catch (SQLException e) { - e.printStackTrace(); - } - } } diff --git a/src/main/java/com/gmail/nossr50/datatypes/player/McMMOPlayer.java b/src/main/java/com/gmail/nossr50/datatypes/player/McMMOPlayer.java index aa12b55f3..69d7d282d 100644 --- a/src/main/java/com/gmail/nossr50/datatypes/player/McMMOPlayer.java +++ b/src/main/java/com/gmail/nossr50/datatypes/player/McMMOPlayer.java @@ -40,6 +40,7 @@ import com.gmail.nossr50.skills.mining.MiningManager; import com.gmail.nossr50.skills.repair.RepairManager; import com.gmail.nossr50.skills.salvage.SalvageManager; import com.gmail.nossr50.skills.smelting.SmeltingManager; +import com.gmail.nossr50.skills.spears.SpearsManager; import com.gmail.nossr50.skills.swords.SwordsManager; import com.gmail.nossr50.skills.taming.TamingManager; import com.gmail.nossr50.skills.tridents.TridentsManager; @@ -63,6 +64,7 @@ import com.gmail.nossr50.util.sounds.SoundType; import java.util.EnumMap; import java.util.Map; import java.util.UUID; +import java.util.logging.Level; import net.kyori.adventure.identity.Identified; import net.kyori.adventure.identity.Identity; import org.bukkit.Bukkit; @@ -171,73 +173,50 @@ public class McMMOPlayer implements Identified { try { initManager(primarySkillType); } catch (InvalidSkillException e) { - e.printStackTrace(); + mcMMO.p.getLogger().log(Level.SEVERE, + "Invalid skill while initializing skill managers for player " + + player.getName() + + ". Contact the plugin developers.", e); } } } //TODO: Add test private void initManager(PrimarySkillType primarySkillType) throws InvalidSkillException { - switch (primarySkillType) { - case ACROBATICS: - skillManagers.put(primarySkillType, new AcrobaticsManager(this)); - break; - case ALCHEMY: - skillManagers.put(primarySkillType, new AlchemyManager(this)); - break; - case ARCHERY: - skillManagers.put(primarySkillType, new ArcheryManager(this)); - break; - case AXES: - skillManagers.put(primarySkillType, new AxesManager(this)); - break; - case CROSSBOWS: - skillManagers.put(primarySkillType, new CrossbowsManager(this)); - break; - case EXCAVATION: - skillManagers.put(primarySkillType, new ExcavationManager(this)); - break; - case FISHING: - skillManagers.put(primarySkillType, new FishingManager(this)); - break; - case HERBALISM: - skillManagers.put(primarySkillType, new HerbalismManager(this)); - break; - case MINING: - skillManagers.put(primarySkillType, new MiningManager(this)); - break; - case REPAIR: - skillManagers.put(primarySkillType, new RepairManager(this)); - break; - case SALVAGE: - skillManagers.put(primarySkillType, new SalvageManager(this)); - break; - case SMELTING: - skillManagers.put(primarySkillType, new SmeltingManager(this)); - break; - case SWORDS: - skillManagers.put(primarySkillType, new SwordsManager(this)); - break; - case TAMING: - skillManagers.put(primarySkillType, new TamingManager(this)); - break; - case TRIDENTS: - skillManagers.put(primarySkillType, new TridentsManager(this)); - break; - case UNARMED: - skillManagers.put(primarySkillType, new UnarmedManager(this)); - break; - case WOODCUTTING: - skillManagers.put(primarySkillType, new WoodcuttingManager(this)); - break; - case MACES: - if (mcMMO.getCompatibilityManager().getMinecraftGameVersion().isAtLeast(1, 21, 0)) { - skillManagers.put(primarySkillType, new MacesManager(this)); - } - break; - default: - throw new InvalidSkillException( - "The skill named has no manager! Contact the devs!"); + final var version = mcMMO.getCompatibilityManager().getMinecraftGameVersion(); + + final SkillManager manager = switch (primarySkillType) { + case ACROBATICS -> new AcrobaticsManager(this); + case ALCHEMY -> new AlchemyManager(this); + case ARCHERY -> new ArcheryManager(this); + case AXES -> new AxesManager(this); + case CROSSBOWS -> new CrossbowsManager(this); + case EXCAVATION -> new ExcavationManager(this); + case FISHING -> new FishingManager(this); + case HERBALISM -> new HerbalismManager(this); + case MINING -> new MiningManager(this); + case REPAIR -> new RepairManager(this); + case SALVAGE -> new SalvageManager(this); + case SMELTING -> new SmeltingManager(this); + case SWORDS -> new SwordsManager(this); + case TAMING -> new TamingManager(this); + case TRIDENTS -> new TridentsManager(this); + case UNARMED -> new UnarmedManager(this); + case WOODCUTTING -> new WoodcuttingManager(this); + + case MACES -> version.isAtLeast(1, 21, 0) + ? new MacesManager(this) + : null; // keep current behavior: no manager on older versions + + case SPEARS -> version.isAtLeast(1, 21, 11) + ? new SpearsManager(this) + : null; // same here + }; + + if (manager != null) { + skillManagers.put(primarySkillType, manager); + } else { + throw new InvalidSkillException("No valid skill manager for skill: " + primarySkillType); } } @@ -369,6 +348,10 @@ public class McMMOPlayer implements Identified { return (SmeltingManager) skillManagers.get(PrimarySkillType.SMELTING); } + public SpearsManager getSpearsManager() { + return (SpearsManager) skillManagers.get(PrimarySkillType.SPEARS); + } + public SwordsManager getSwordsManager() { return (SwordsManager) skillManagers.get(PrimarySkillType.SWORDS); } diff --git a/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java b/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java index c9c2e9d83..1c61af81d 100644 --- a/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java +++ b/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java @@ -87,7 +87,7 @@ public class PlayerProfile { this.loaded = isLoaded; } - public PlayerProfile(@NotNull String playerName, UUID uuid, boolean isLoaded, int startingLvl) { + public PlayerProfile(@NotNull String playerName, @Nullable UUID uuid, boolean isLoaded, int startingLvl) { this(playerName, uuid, startingLvl); this.loaded = isLoaded; } diff --git a/src/main/resources/locale/locale_en_US.properties b/src/main/resources/locale/locale_en_US.properties index ed051f5ad..435273742 100644 --- a/src/main/resources/locale/locale_en_US.properties +++ b/src/main/resources/locale/locale_en_US.properties @@ -27,6 +27,7 @@ JSON.Salvage=Salvage JSON.Swords=Swords JSON.Taming=Taming JSON.Tridents=Tridents +JSON.Spears=Spears JSON.Maces=Maces JSON.Unarmed=Unarmed JSON.Woodcutting=Woodcutting @@ -98,6 +99,7 @@ Overhaul.Name.Smelting=Smelting Overhaul.Name.Swords=Swords Overhaul.Name.Taming=Taming Overhaul.Name.Tridents=Tridents +Overhaul.Name.Spears=Spears Overhaul.Name.Maces=Maces Overhaul.Name.Unarmed=Unarmed Overhaul.Name.Woodcutting=Woodcutting @@ -125,6 +127,7 @@ XPBar.Smelting=Smelting Lv.&6{0} XPBar.Swords=Swords Lv.&6{0} XPBar.Taming=Taming Lv.&6{0} XPBar.Tridents=Tridents Lv.&6{0} +XPBar.Spears=Spears Lv.&6{0} XPBar.Maces=Maces Lv.&6{0} XPBar.Unarmed=Unarmed Lv.&6{0} XPBar.Woodcutting=Woodcutting Lv.&6{0} @@ -474,6 +477,16 @@ Maces.SubSkill.Cripple.Stat=Cripple Chance Maces.SubSkill.Cripple.Stat.Extra=[[DARK_AQUA]]Cripple Duration: &e{0}s&a vs Players, &e{1}s&a vs Mobs. Maces.Listener=Maces: +#SPEARS +Spears.SkillName=SPEARS +Spears.Ability.Lower=&7You lower your spear. +Spears.Ability.Ready=&3You &6ready&3 your spear. +Spears.SubSkill.SpearsLimitBreak.Name=Spears Limit Break +Spears.SubSkill.SpearsLimitBreak.Description=Breaking your limits. Increased damage against tough opponents. Intended for PVP, up to server settings for whether it will boost damage in PVE. +Spears.SubSkill.SpearsLimitBreak.Stat=Limit Break Max DMG +Spears.SubSkill.SpearAbility.Name=WIP +Spears.Listener=Spears: + #SWORDS Swords.Ability.Lower=&7You lower your sword. Swords.Ability.Ready=&3You &6ready&3 your Sword. @@ -913,6 +926,7 @@ Commands.XPGain.Repair=Repairing Commands.XPGain.Swords=Attacking Monsters Commands.XPGain.Taming=Animal Taming, or combat w/ your wolves Commands.XPGain.Tridents=Attacking Monsters +Commands.XPGain.Spears=Attacking Monsters Commands.XPGain.Unarmed=Attacking Monsters Commands.XPGain.Woodcutting=Chopping down trees Commands.XPGain=&8XP GAIN: &f{0} @@ -1047,12 +1061,12 @@ Guides.Woodcutting.Section.1=&3How does Tree Feller work?\n&eTree Feller is an a Guides.Woodcutting.Section.2=&3How does Leaf Blower work?\n&eLeaf Blower is a passive ability that will cause leaf\n&eblocks to break instantly when hit with an axe. By default,\nðis ability unlocks at level 100. Guides.Woodcutting.Section.3=&3How do Double Drops work?\n&eThis passive ability gives you a chance to obtain an extra\n&eblock for every log you chop. # Crossbows -Guides.Crossbows.Section.0=&3About Crossbows:\n&eCrossbows is all about shooting with your crossbow.\n\n&3XP GAIN:\n&eXP is gained whenever you shoot mobs with a crossbow.\nThis is a WIP skill and more information will be added soon. +Guides.Crossbows.Section.0=&3About Crossbows:\n&eCrossbows is all about shooting with your crossbow.\n\n&3XP GAIN:\n&eXP is gained whenever you shoot mobs with a crossbow. Guides.Crossbows.Section.1=&3How does Trickshot work?\n&eTrickshot is an passive ability, you shoot your bolts at a shallow angle with a crossbow to attempt a Trickshot. This will cause the arrow to ricochet off of blocks and potentially hit a target. The number of potential bounces from a ricochet depend on the rank of Trickshot. # Tridents -Guides.Tridents.Section.0=&3About Tridents:\n&eTridents skill involves impaling foes with your trident.\n\n&3XP GAIN:\n&eXP is gained whenever you hit mobs with a trident.\nThis is a WIP skill and more information will be added soon. -Guides.Maces.Section.0=&3About Maces:\n&eMaces is all about smashing your foes with a mace.\n\n&3XP GAIN:\n&eXP is gained whenever you hit mobs with a mace.\nThis is a WIP skill and more information will be added soon. - +Guides.Tridents.Section.0=&3About Tridents:\n&eTridents skill involves impaling foes with your trident.\n\n&3XP GAIN:\n&eXP is gained whenever you hit mobs with a trident. +Guides.Maces.Section.0=&3About Maces:\n&eMaces is all about smashing your foes with a mace.\n\n&3XP GAIN:\n&eXP is gained whenever you hit mobs with a mace. +Guides.Spears.Section.0=&3About Spears:\n&eSpears is all about impaling your foes with a spear.\n\n&3XP GAIN:\n&eXP is gained whenever you hit mobs with a spear. #INSPECT Inspect.Offline= &cYou do not have permission to inspect offline players! Inspect.OfflineStats=mcMMO Stats for Offline Player &e{0} diff --git a/src/test/java/com/gmail/nossr50/database/SQLDatabaseManagerTest.java b/src/test/java/com/gmail/nossr50/database/SQLDatabaseManagerTest.java index 959777ab9..ad2d486d6 100644 --- a/src/test/java/com/gmail/nossr50/database/SQLDatabaseManagerTest.java +++ b/src/test/java/com/gmail/nossr50/database/SQLDatabaseManagerTest.java @@ -1,245 +1,1282 @@ -//package com.gmail.nossr50.database; -// -//import com.gmail.nossr50.config.AdvancedConfig; -//import com.gmail.nossr50.config.GeneralConfig; -//import com.gmail.nossr50.datatypes.MobHealthbarType; -//import com.gmail.nossr50.datatypes.player.PlayerProfile; -//import com.gmail.nossr50.datatypes.skills.PrimarySkillType; -//import com.gmail.nossr50.mcMMO; -//import com.gmail.nossr50.util.compat.CompatibilityManager; -//import com.gmail.nossr50.util.platform.MinecraftGameVersion; -//import com.gmail.nossr50.util.skills.SkillTools; -//import com.gmail.nossr50.util.upgrade.UpgradeManager; -//import org.bukkit.entity.Player; -//import org.jetbrains.annotations.NotNull; -//import org.junit.jupiter.api.*; -//import org.mockito.MockedStatic; -//import org.mockito.Mockito; -// -//import java.util.logging.Logger; -// -//import static org.junit.jupiter.api.Assertions.*; -//import static org.mockito.ArgumentMatchers.any; -//import static org.mockito.Mockito.when; -// -//class SQLDatabaseManagerTest { -// private final static @NotNull Logger logger = Logger.getLogger(Logger.GLOBAL_LOGGER_NAME); -// static MockedStatic mockedMcMMO; -// SQLDatabaseManager sqlDatabaseManager; -// static GeneralConfig generalConfig; -// static AdvancedConfig advancedConfig; -// static UpgradeManager upgradeManager; -// static CompatibilityManager compatibilityManager; -// static SkillTools skillTools; -// -// @BeforeAll -// static void setUpAll() { -// // stub mcMMO.p -// mockedMcMMO = Mockito.mockStatic(mcMMO.class); -// mcMMO.p = Mockito.mock(mcMMO.class); -// when(mcMMO.p.getLogger()).thenReturn(logger); -// -// // general config mock -// mockGeneralConfig(); -// -// // advanced config mock -// advancedConfig = Mockito.mock(AdvancedConfig.class); -// when(mcMMO.p.getAdvancedConfig()).thenReturn(advancedConfig); -// -// // starting level -// when(mcMMO.p.getAdvancedConfig().getStartingLevel()).thenReturn(0); -// -// // wire skill tools -// skillTools = new SkillTools(mcMMO.p); -// when(mcMMO.p.getSkillTools()).thenReturn(skillTools); -// -// // compatibility manager mock -// compatibilityManager = Mockito.mock(CompatibilityManager.class); -// when(mcMMO.getCompatibilityManager()).thenReturn(compatibilityManager); -// when(compatibilityManager.getMinecraftGameVersion()).thenReturn(new MinecraftGameVersion(1, 20, 4)); -// -// // upgrade manager mock -// upgradeManager = Mockito.mock(UpgradeManager.class); -// when(mcMMO.getUpgradeManager()).thenReturn(upgradeManager); -// -// // don't trigger upgrades -// when(mcMMO.getUpgradeManager().shouldUpgrade(any())).thenReturn(false); -// } -// -// private static void mockGeneralConfig() { -// generalConfig = Mockito.mock(GeneralConfig.class); -// when(generalConfig.getLocale()).thenReturn("en_US"); -// when(mcMMO.p.getGeneralConfig()).thenReturn(generalConfig); -// -// // max pool size -// when(mcMMO.p.getGeneralConfig().getMySQLMaxPoolSize(SQLDatabaseManager.PoolIdentifier.MISC)) -// .thenReturn(10); -// when(mcMMO.p.getGeneralConfig().getMySQLMaxPoolSize(SQLDatabaseManager.PoolIdentifier.LOAD)) -// .thenReturn(20); -// when(mcMMO.p.getGeneralConfig().getMySQLMaxPoolSize(SQLDatabaseManager.PoolIdentifier.SAVE)) -// .thenReturn(20); -// -// // max connections -// when(mcMMO.p.getGeneralConfig().getMySQLMaxConnections(SQLDatabaseManager.PoolIdentifier.MISC)) -// .thenReturn(30); -// when(mcMMO.p.getGeneralConfig().getMySQLMaxConnections(SQLDatabaseManager.PoolIdentifier.LOAD)) -// .thenReturn(30); -// when(mcMMO.p.getGeneralConfig().getMySQLMaxConnections(SQLDatabaseManager.PoolIdentifier.SAVE)) -// .thenReturn(30); -// -// // table prefix -// when(mcMMO.p.getGeneralConfig().getMySQLTablePrefix()).thenReturn("mcmmo_"); -// -// // public key retrieval -// when(mcMMO.p.getGeneralConfig().getMySQLPublicKeyRetrieval()).thenReturn(true); -// -// // debug -// when(mcMMO.p.getGeneralConfig().getMySQLDebug()).thenReturn(true); -// -// // use mysql -// when(mcMMO.p.getGeneralConfig().getUseMySQL()).thenReturn(true); -// -// // use ssl -// when(mcMMO.p.getGeneralConfig().getMySQLSSL()).thenReturn(true); -// -// // username -// when(mcMMO.p.getGeneralConfig().getMySQLUserName()).thenReturn("sa"); -// -// // password -// when(mcMMO.p.getGeneralConfig().getMySQLUserPassword()).thenReturn(""); -// -// // host -// when(mcMMO.p.getGeneralConfig().getMySQLServerName()).thenReturn("localhost"); -// -// // unused mob health bar thingy -// when(mcMMO.p.getGeneralConfig().getMobHealthbarDefault()).thenReturn(MobHealthbarType.HEARTS); -// } -// -// @BeforeEach -// void setUp() { -// assertNull(sqlDatabaseManager); -// sqlDatabaseManager = new SQLDatabaseManager(logger, "org.h2.Driver", true); -// } -// -// @AfterEach -// void tearDown() { -// sqlDatabaseManager = null; -// } -// -// @AfterAll -// static void tearDownAll() { -// mockedMcMMO.close(); -// } -// -// @Test -// void testGetConnectionMisc() throws Exception { -// assertNotNull(sqlDatabaseManager.getConnection(SQLDatabaseManager.PoolIdentifier.MISC)); -// } -// -// @Test -// void testGetConnectionLoad() throws Exception { -// assertNotNull(sqlDatabaseManager.getConnection(SQLDatabaseManager.PoolIdentifier.LOAD)); -// } -// -// @Test -// void testGetConnectionSave() throws Exception { -// assertNotNull(sqlDatabaseManager.getConnection(SQLDatabaseManager.PoolIdentifier.SAVE)); -// } -// -// @Test -// void testNewUser() { -// Player player = Mockito.mock(Player.class); -// when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); -// when(player.getName()).thenReturn("nossr50"); -// sqlDatabaseManager.newUser(player); -// } -// -// @Test -// void testNewUserGetSkillLevel() { -// Player player = Mockito.mock(Player.class); -// when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); -// when(player.getName()).thenReturn("nossr50"); -// PlayerProfile playerProfile = sqlDatabaseManager.newUser(player); -// -// for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { -// assertEquals(0, playerProfile.getSkillLevel(primarySkillType)); -// } -// } -// -// @Test -// void testNewUserGetSkillXpLevel() { -// Player player = Mockito.mock(Player.class); -// when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); -// when(player.getName()).thenReturn("nossr50"); -// PlayerProfile playerProfile = sqlDatabaseManager.newUser(player); -// -// for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { -// assertEquals(0, playerProfile.getSkillXpLevel(primarySkillType)); -// } -// } -// -// @Test -// void testSaveSkillLevelValues() { -// Player player = Mockito.mock(Player.class); -// when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); -// when(player.getName()).thenReturn("nossr50"); -// PlayerProfile playerProfile = sqlDatabaseManager.newUser(player); -// -// // Validate values are starting from zero -// for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { -// assertEquals(0, playerProfile.getSkillXpLevel(primarySkillType)); -// } -// -// // Change values -// for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { -// playerProfile.modifySkill(primarySkillType, 1 + primarySkillType.ordinal()); -// } -// -// boolean saveSuccess = sqlDatabaseManager.saveUser(playerProfile); -// assertTrue(saveSuccess); -// -// PlayerProfile retrievedUser = sqlDatabaseManager.loadPlayerProfile(player.getName()); -// -// // Check that values got saved -// for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { -// if (primarySkillType == PrimarySkillType.SALVAGE || primarySkillType == PrimarySkillType.SMELTING) { -// // Child skills are not saved, but calculated -// continue; -// } -// -// assertEquals(1 + primarySkillType.ordinal(), retrievedUser.getSkillLevel(primarySkillType)); -// } -// } -// -// @Test -// void testSaveSkillXpValues() { -// Player player = Mockito.mock(Player.class); -// when(player.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); -// when(player.getName()).thenReturn("nossr50"); -// PlayerProfile playerProfile = sqlDatabaseManager.newUser(player); -// -// // Validate values are starting from zero -// for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { -// assertEquals(0, playerProfile.getSkillXpLevel(primarySkillType)); -// } -// -// // Change values -// for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { -// playerProfile.setSkillXpLevel(primarySkillType, 1 + primarySkillType.ordinal()); -// } -// -// sqlDatabaseManager.saveUser(playerProfile); -// -// PlayerProfile retrievedUser = sqlDatabaseManager.loadPlayerProfile(player.getName()); -// -// // Check that values got saved -// for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { -// if (primarySkillType == PrimarySkillType.SALVAGE || primarySkillType == PrimarySkillType.SMELTING) { -// // Child skills are not saved, but calculated -// continue; -// } -// -// assertEquals(1 + primarySkillType.ordinal(), retrievedUser.getSkillXpLevel(primarySkillType)); -// } -// } -//} +package com.gmail.nossr50.database; + +import com.gmail.nossr50.api.exceptions.InvalidSkillException; +import com.gmail.nossr50.config.AdvancedConfig; +import com.gmail.nossr50.config.GeneralConfig; +import com.gmail.nossr50.datatypes.MobHealthbarType; +import com.gmail.nossr50.datatypes.database.DatabaseType; +import com.gmail.nossr50.datatypes.database.PlayerStat; +import com.gmail.nossr50.datatypes.player.PlayerProfile; +import com.gmail.nossr50.datatypes.skills.PrimarySkillType; +import com.gmail.nossr50.mcMMO; +import com.gmail.nossr50.util.compat.CompatibilityManager; +import com.gmail.nossr50.util.platform.MinecraftGameVersion; +import com.gmail.nossr50.util.skills.SkillTools; +import com.gmail.nossr50.util.upgrade.UpgradeManager; +import java.util.List; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.mariadb.MariaDBContainer; +import org.testcontainers.mysql.MySQLContainer; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@TestInstance(Lifecycle.PER_CLASS) +@Testcontainers +class SQLDatabaseManagerTest { + + private static final @NotNull Logger logger = Logger.getLogger(Logger.GLOBAL_LOGGER_NAME); + + @Container + private static final MySQLContainer MYSQL_CONTAINER = + new MySQLContainer("mysql:8.0") + .withDatabaseName("mcmmo") + .withUsername("test") + .withPassword("test"); + + @Container + private static final MariaDBContainer MARIADB_CONTAINER = + new MariaDBContainer("mariadb:10.11") + .withDatabaseName("mcmmo") + .withUsername("test") + .withPassword("test"); + + private static MockedStatic mockedMcMMO; + private static GeneralConfig generalConfig; + private static AdvancedConfig advancedConfig; + private static UpgradeManager upgradeManager; + private static CompatibilityManager compatibilityManager; + private static SkillTools skillTools; + + // --- DB flavors you support --- + enum DbFlavor { + MYSQL, + MARIADB + } + + static Stream dbFlavors() { + return Stream.of(DbFlavor.MYSQL, DbFlavor.MARIADB); + } + + @BeforeAll + void setUpAll() { + // GIVEN a fully mocked mcMMO environment + compatibilityManager = mock(CompatibilityManager.class); + MinecraftGameVersion minecraftGameVersion = mock(MinecraftGameVersion.class); + when(compatibilityManager.getMinecraftGameVersion()).thenReturn(minecraftGameVersion); + when(minecraftGameVersion.isAtLeast(anyInt(), anyInt(), anyInt())).thenReturn(true); + + mockedMcMMO = Mockito.mockStatic(mcMMO.class); + mcMMO.p = Mockito.mock(mcMMO.class); + when(mcMMO.p.getLogger()).thenReturn(logger); + when(mcMMO.getCompatibilityManager()).thenReturn(compatibilityManager); + + mockGeneralConfigBase(); + + advancedConfig = Mockito.mock(AdvancedConfig.class); + when(mcMMO.p.getAdvancedConfig()).thenReturn(advancedConfig); + when(mcMMO.p.getAdvancedConfig().getStartingLevel()).thenReturn(0); + + skillTools = new SkillTools(mcMMO.p); + when(mcMMO.p.getSkillTools()).thenReturn(skillTools); + + compatibilityManager = Mockito.mock(CompatibilityManager.class); + when(mcMMO.getCompatibilityManager()).thenReturn(compatibilityManager); + when(compatibilityManager.getMinecraftGameVersion()) + .thenReturn(new MinecraftGameVersion(1, 20, 4)); + + upgradeManager = Mockito.mock(UpgradeManager.class); + when(mcMMO.getUpgradeManager()).thenReturn(upgradeManager); + when(mcMMO.getUpgradeManager().shouldUpgrade(any())).thenReturn(false); + + // Null player lookup, shouldn't affect tests + Server server = mock(Server.class); + when(mcMMO.p.getServer()).thenReturn(server); + when(server.getPlayerExact(anyString())) + .thenReturn(null); + } + + @AfterAll + static void tearDownAll() { + mockedMcMMO.close(); + } + + private static void mockGeneralConfigBase() { + generalConfig = Mockito.mock(GeneralConfig.class); + when(mcMMO.p.getGeneralConfig()).thenReturn(generalConfig); + + when(generalConfig.getLocale()).thenReturn("en_US"); + + // pool sizes + when(generalConfig.getMySQLMaxPoolSize(SQLDatabaseManager.PoolIdentifier.MISC)) + .thenReturn(10); + when(generalConfig.getMySQLMaxPoolSize(SQLDatabaseManager.PoolIdentifier.LOAD)) + .thenReturn(20); + when(generalConfig.getMySQLMaxPoolSize(SQLDatabaseManager.PoolIdentifier.SAVE)) + .thenReturn(20); + + // max connections + when(generalConfig.getMySQLMaxConnections(SQLDatabaseManager.PoolIdentifier.MISC)) + .thenReturn(30); + when(generalConfig.getMySQLMaxConnections(SQLDatabaseManager.PoolIdentifier.LOAD)) + .thenReturn(30); + when(generalConfig.getMySQLMaxConnections(SQLDatabaseManager.PoolIdentifier.SAVE)) + .thenReturn(30); + + // table prefix + when(generalConfig.getMySQLTablePrefix()).thenReturn("mcmmo_"); + + // public key retrieval + when(generalConfig.getMySQLPublicKeyRetrieval()).thenReturn(true); + + // use mysql + when(generalConfig.getUseMySQL()).thenReturn(true); + + // SSL effectively off for >= 1.17 + when(generalConfig.getMySQLSSL()).thenReturn(true); + + // mob health bar default + when(generalConfig.getMobHealthbarDefault()).thenReturn(MobHealthbarType.HEARTS); + } + + private JdbcDatabaseContainer containerFor(DbFlavor flavor) { + return switch (flavor) { + case MYSQL -> MYSQL_CONTAINER; + case MARIADB -> MARIADB_CONTAINER; + }; + } + + /** + * Wire the mcMMO GeneralConfig mocks to a specific running container, + * then construct a fresh SQLDatabaseManager using the MySQL driver + * (also works for MariaDB). + */ + private SQLDatabaseManager createManagerFor(DbFlavor flavor) { + JdbcDatabaseContainer container = containerFor(flavor); + + when(generalConfig.getMySQLServerName()).thenReturn(container.getHost()); + when(generalConfig.getMySQLServerPort()).thenReturn(container.getFirstMappedPort()); + when(generalConfig.getMySQLDatabaseName()).thenReturn(container.getDatabaseName()); + when(generalConfig.getMySQLUserName()).thenReturn(container.getUsername()); + when(generalConfig.getMySQLUserPassword()).thenReturn(container.getPassword()); + + return new SQLDatabaseManager(logger, "com.mysql.cj.jdbc.Driver"); + } + + /** + * Helper to wipe all core mcMMO SQL tables for a given DB flavor. + * This keeps tests isolated. + */ + private void truncateAllCoreTables(DbFlavor flavor) { + SQLDatabaseManager databaseManager = createManagerFor(flavor); + try (Connection connection = databaseManager.getConnection(SQLDatabaseManager.PoolIdentifier.MISC); + Statement statement = connection.createStatement()) { + + // Order matters because of foreign key constraints in some setups + // noinspection SqlWithoutWhere + statement.executeUpdate("DELETE FROM mcmmo_cooldowns"); + // noinspection SqlWithoutWhere + statement.executeUpdate("DELETE FROM mcmmo_experience"); + // noinspection SqlWithoutWhere + statement.executeUpdate("DELETE FROM mcmmo_huds"); + // noinspection SqlWithoutWhere + statement.executeUpdate("DELETE FROM mcmmo_skills"); + // noinspection SqlWithoutWhere + statement.executeUpdate("DELETE FROM mcmmo_users"); + } catch (SQLException exception) { + throw new RuntimeException("Failed to truncate core tables", exception); + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // Connection / basic wiring + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - getConnection for all pool identifiers") + @MethodSource("dbFlavors") + void whenGettingConnectionsForAllPoolsShouldReturnNonNullConnections(DbFlavor flavor) throws Exception { + // GIVEN a database manager for the selected flavor + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + try { + // WHEN requesting connections for all pool identifiers + for (SQLDatabaseManager.PoolIdentifier poolIdentifier : SQLDatabaseManager.PoolIdentifier.values()) { + Connection connection = databaseManager.getConnection(poolIdentifier); + + // THEN each connection should be non-null and open + assertThat(connection) + .as("Connection for pool %s should not be null", poolIdentifier) + .isNotNull(); + } + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // New user creation & initialization + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - newUser initializes skill levels and XP") + @MethodSource("dbFlavors") + void whenCreatingNewUserShouldInitializeSkillLevelsAndXpToStartingValues(DbFlavor flavor) { + // GIVEN a new player and database manager + SQLDatabaseManager databaseManager = createManagerFor(flavor); + Player player = Mockito.mock(Player.class); + UUID playerUuid = UUID.randomUUID(); + String playerName = "nossr50_" + flavor.name().toLowerCase(); + + when(player.getUniqueId()).thenReturn(playerUuid); + when(player.getName()).thenReturn(playerName); + + try { + // WHEN creating a new user + PlayerProfile playerProfile = databaseManager.newUser(player); + + // THEN the profile should be loaded with all skills and XP at starting values (0) + assertThat(playerProfile).isNotNull(); + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + assertThat(playerProfile.getSkillLevel(primarySkillType)) + .as("Skill level for %s", primarySkillType) + .isZero(); + assertThat(playerProfile.getSkillXpLevel(primarySkillType)) + .as("XP level for %s", primarySkillType) + .isZero(); + } + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // Saving skill levels / XP + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - saveUser persists skill level values") + @MethodSource("dbFlavors") + void whenSavingSkillLevelValuesShouldPersistToDatabase(DbFlavor flavor) { + // GIVEN a new user with modified skill levels + SQLDatabaseManager databaseManager = createManagerFor(flavor); + Player player = Mockito.mock(Player.class); + UUID playerUuid = UUID.randomUUID(); + String playerName = "nossr50_levels_" + flavor.name().toLowerCase(); + + when(player.getUniqueId()).thenReturn(playerUuid); + when(player.getName()).thenReturn(playerName); + + try { + PlayerProfile playerProfile = databaseManager.newUser(player); + + // AND all XP start at zero + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + assertThat(playerProfile.getSkillXpLevel(primarySkillType)) + .as("Initial XP for %s", primarySkillType) + .isZero(); + } + + // WHEN we modify levels and save + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + playerProfile.modifySkill(primarySkillType, 1 + primarySkillType.ordinal()); + } + + boolean saveSucceeded = databaseManager.saveUser(playerProfile); + + // THEN save should succeed + assertThat(saveSucceeded).isTrue(); + + // AND the retrieved user should have matching levels (except child skills) + PlayerProfile retrievedUser = databaseManager.loadPlayerProfile(player.getName()); + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + if (primarySkillType == PrimarySkillType.SALVAGE + || primarySkillType == PrimarySkillType.SMELTING) { + continue; + } + + assertThat(retrievedUser.getSkillLevel(primarySkillType)) + .as("Saved level for %s", primarySkillType) + .isEqualTo(1 + primarySkillType.ordinal()); + } + } finally { + databaseManager.onDisable(); + } + } + + @ParameterizedTest(name = "{0} - saveUser persists skill XP values") + @MethodSource("dbFlavors") + void whenSavingSkillXpValuesShouldPersistToDatabase(DbFlavor flavor) { + // GIVEN a new user with modified XP levels + SQLDatabaseManager databaseManager = createManagerFor(flavor); + Player player = Mockito.mock(Player.class); + UUID playerUuid = UUID.randomUUID(); + String playerName = "nossr50_xp_" + flavor.name().toLowerCase(); + + when(player.getUniqueId()).thenReturn(playerUuid); + when(player.getName()).thenReturn(playerName); + + try { + PlayerProfile playerProfile = databaseManager.newUser(player); + + // AND all XP start at zero + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + assertThat(playerProfile.getSkillXpLevel(primarySkillType)) + .as("Initial XP for %s", primarySkillType) + .isZero(); + } + + // WHEN we set XP values and save + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + playerProfile.setSkillXpLevel(primarySkillType, 1 + primarySkillType.ordinal()); + } + + boolean saveSucceeded = databaseManager.saveUser(playerProfile); + + // THEN save should succeed + assertThat(saveSucceeded).isTrue(); + + // AND the retrieved user should have matching XP (except child skills) + PlayerProfile retrievedUser = databaseManager.loadPlayerProfile(player.getName()); + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + if (primarySkillType == PrimarySkillType.SALVAGE + || primarySkillType == PrimarySkillType.SMELTING) { + continue; + } + + assertThat(retrievedUser.getSkillXpLevel(primarySkillType)) + .as("Saved XP for %s", primarySkillType) + .isEqualTo(1 + primarySkillType.ordinal()); + } + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // Schema upgrades (legacy spears) + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - upgrades legacy schema to add spears columns") + @MethodSource("dbFlavors") + void whenUpgradingLegacySchemaShouldAddSpearsColumns(DbFlavor flavor) throws Exception { + // GIVEN a legacy schema without spears columns + prepareLegacySchemaWithoutSpears(flavor); + + // AND spears columns do not exist yet + assertThat(columnExists(flavor, "mcmmo_skills", "spears")) + .as("Legacy skills table should NOT have spears column") + .isFalse(); + assertThat(columnExists(flavor, "mcmmo_experience", "spears")) + .as("Legacy experience table should NOT have spears column") + .isFalse(); + assertThat(columnExists(flavor, "mcmmo_cooldowns", "spears")) + .as("Legacy cooldowns table should NOT have spears column") + .isFalse(); + + // WHEN constructing a manager (which runs structure checks + upgrade logic) + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + try { + // THEN spears columns should be added to all core tables + assertThat(columnExists(flavor, "mcmmo_skills", "spears")) + .as("Skills table should have spears after upgrade") + .isTrue(); + assertThat(columnExists(flavor, "mcmmo_experience", "spears")) + .as("Experience table should have spears after upgrade") + .isTrue(); + assertThat(columnExists(flavor, "mcmmo_cooldowns", "spears")) + .as("Cooldowns table should have spears after upgrade") + .isTrue(); + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // New user -> rows in all core tables + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - newUser creates rows in all tables") + @MethodSource("dbFlavors") + void whenCreatingNewUserShouldCreateRowsInAllCoreTables(DbFlavor flavor) throws Exception { + // GIVEN a clean database and a new user + SQLDatabaseManager databaseManager = createManagerFor(flavor); + truncateAllCoreTables(flavor); + + Player player = Mockito.mock(Player.class); + UUID playerUuid = UUID.randomUUID(); + String playerName = "user_rows_" + flavor.name().toLowerCase(); + + when(player.getUniqueId()).thenReturn(playerUuid); + when(player.getName()).thenReturn(playerName); + + try { + databaseManager.newUser(player); + + JdbcDatabaseContainer container = containerFor(flavor); + try (Connection connection = DriverManager.getConnection( + container.getJdbcUrl(), container.getUsername(), container.getPassword()); + Statement statement = connection.createStatement()) { + + // THEN one row exists in mcmmo_users + try (ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM mcmmo_users WHERE user = '" + playerName + "'")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)).isEqualTo(1); + } + + // AND one row exists in mcmmo_skills + try (ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM mcmmo_skills s JOIN mcmmo_users u ON s.user_id = u.id " + + "WHERE u.user = '" + playerName + "'")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)).isEqualTo(1); + } + + // AND one row exists in mcmmo_experience + try (ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM mcmmo_experience e JOIN mcmmo_users u ON e.user_id = u.id " + + "WHERE u.user = '" + playerName + "'")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)).isEqualTo(1); + } + + // AND one row exists in mcmmo_cooldowns + try (ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM mcmmo_cooldowns c JOIN mcmmo_users u ON c.user_id = u.id " + + "WHERE u.user = '" + playerName + "'")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)).isEqualTo(1); + } + + // AND one row exists in mcmmo_huds + try (ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM mcmmo_huds h JOIN mcmmo_users u ON h.user_id = u.id " + + "WHERE u.user = '" + playerName + "'")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)).isEqualTo(1); + } + } + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // getStoredUsers + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - getStoredUsers returns usernames") + @MethodSource("dbFlavors") + void whenGettingStoredUsersShouldReturnPersistedUsernames(DbFlavor flavor) { + // GIVEN a number of persisted users + SQLDatabaseManager databaseManager = createManagerFor(flavor); + truncateAllCoreTables(flavor); + + String baseName = "stored_user_" + flavor.name().toLowerCase(); + + try { + for (int index = 0; index < 3; index++) { + Player player = Mockito.mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getName()).thenReturn(baseName + "_" + index); + databaseManager.newUser(player); + } + + // WHEN retrieving stored users + var storedUsers = databaseManager.getStoredUsers(); + + // THEN all created usernames should be present + assertThat(storedUsers) + .contains(baseName + "_0", baseName + "_1", baseName + "_2"); + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // saveUserUUID / saveUserUUIDs + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - saveUserUUID updates uuid column and lookup") + @MethodSource("dbFlavors") + void whenSavingSingleUserUuidShouldUpdateUuidColumnAndLookupBehavior(DbFlavor flavor) throws Exception { + // GIVEN a single persisted user + SQLDatabaseManager databaseManager = createManagerFor(flavor); + truncateAllCoreTables(flavor); + + String username = "uuid_single_" + flavor.name().toLowerCase(); + Player player = Mockito.mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.getName()).thenReturn(username); + + try { + databaseManager.newUser(player); + UUID newUuid = UUID.randomUUID(); + + // WHEN updating the user's UUID + boolean updated = databaseManager.saveUserUUID(username, newUuid); + + // THEN the update should succeed + assertThat(updated).isTrue(); + + // AND the UUID column should match in the database + JdbcDatabaseContainer container = containerFor(flavor); + try (Connection connection = DriverManager.getConnection( + container.getJdbcUrl(), container.getUsername(), container.getPassword()); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery( + "SELECT uuid FROM mcmmo_users WHERE user = '" + username + "'")) { + + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getString(1)).isEqualTo(newUuid.toString()); + } + + // AND the old UUID should not resolve a profile + PlayerProfile oldProfile = databaseManager.loadPlayerProfile(UUID.randomUUID()); + assertThat(oldProfile.isLoaded()).isFalse(); + + // AND the new UUID should resolve the profile + PlayerProfile newProfile = databaseManager.loadPlayerProfile(newUuid); + assertThat(newProfile.isLoaded()).isTrue(); + assertThat(newProfile.getPlayerName()).isEqualTo(username); + } finally { + databaseManager.onDisable(); + } + } + + @ParameterizedTest(name = "{0} - saveUserUUIDs bulk updates multiple rows") + @MethodSource("dbFlavors") + void whenSavingBulkUserUuidsShouldUpdateAllRows(DbFlavor flavor) throws Exception { + // GIVEN two persisted users + SQLDatabaseManager databaseManager = createManagerFor(flavor); + truncateAllCoreTables(flavor); + + String firstUsername = "uuid_bulk_1_" + flavor.name().toLowerCase(); + String secondUsername = "uuid_bulk_2_" + flavor.name().toLowerCase(); + + Player firstPlayer = Mockito.mock(Player.class); + when(firstPlayer.getUniqueId()).thenReturn(UUID.randomUUID()); + when(firstPlayer.getName()).thenReturn(firstUsername); + databaseManager.newUser(firstPlayer); + + Player secondPlayer = Mockito.mock(Player.class); + when(secondPlayer.getUniqueId()).thenReturn(UUID.randomUUID()); + when(secondPlayer.getName()).thenReturn(secondUsername); + databaseManager.newUser(secondPlayer); + + Map uuidUpdates = new HashMap<>(); + UUID firstNewUuid = UUID.randomUUID(); + UUID secondNewUuid = UUID.randomUUID(); + uuidUpdates.put(firstUsername, firstNewUuid); + uuidUpdates.put(secondUsername, secondNewUuid); + + try { + // WHEN performing a bulk UUID update + boolean updateSucceeded = databaseManager.saveUserUUIDs(uuidUpdates); + + // THEN the update should succeed + assertThat(updateSucceeded).isTrue(); + + // AND both rows should reflect the new UUID values + JdbcDatabaseContainer container = containerFor(flavor); + try (Connection connection = DriverManager.getConnection( + container.getJdbcUrl(), container.getUsername(), container.getPassword()); + Statement statement = connection.createStatement()) { + + try (ResultSet resultSet = statement.executeQuery( + "SELECT user, uuid FROM mcmmo_users WHERE user IN ('" + firstUsername + "','" + + secondUsername + "')")) { + int rowsSeen = 0; + while (resultSet.next()) { + String user = resultSet.getString("user"); + String uuid = resultSet.getString("uuid"); + if (user.equals(firstUsername)) { + assertThat(uuid).isEqualTo(firstNewUuid.toString()); + rowsSeen++; + } else if (user.equals(secondUsername)) { + assertThat(uuid).isEqualTo(secondNewUuid.toString()); + rowsSeen++; + } + } + assertThat(rowsSeen).isEqualTo(2); + } + } + + // AND getStoredUsers still contains both names + var storedUsers = databaseManager.getStoredUsers(); + assertThat(storedUsers).contains(firstUsername, secondUsername); + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // purgePowerlessUsers + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - purgePowerlessUsers removes only zero-skill users") + @MethodSource("dbFlavors") + void whenPurgingPowerlessUsersShouldRemoveOnlyZeroSkillUsers(DbFlavor flavor) throws Exception { + // GIVEN one powerless user (all skills zero) and one powered user + SQLDatabaseManager databaseManager = createManagerFor(flavor); + truncateAllCoreTables(flavor); + + Player powerlessPlayer = Mockito.mock(Player.class); + when(powerlessPlayer.getUniqueId()).thenReturn(UUID.randomUUID()); + when(powerlessPlayer.getName()).thenReturn("powerless_" + flavor.name().toLowerCase()); + databaseManager.newUser(powerlessPlayer); + + Player poweredPlayer = Mockito.mock(Player.class); + UUID poweredUuid = UUID.randomUUID(); + when(poweredPlayer.getUniqueId()).thenReturn(poweredUuid); + when(poweredPlayer.getName()).thenReturn("powered_" + flavor.name().toLowerCase()); + PlayerProfile poweredProfile = databaseManager.newUser(poweredPlayer); + poweredProfile.modifySkill(PrimarySkillType.MINING, 10); + assertThat(databaseManager.saveUser(poweredProfile)).isTrue(); + + // WHEN purging powerless users + int purgedCount = databaseManager.purgePowerlessUsers(); + + // THEN exactly one user should be purged + assertThat(purgedCount) + .as("Exactly one powerless user should be purged") + .isEqualTo(1); + + JdbcDatabaseContainer container = containerFor(flavor); + try (Connection connection = DriverManager.getConnection( + container.getJdbcUrl(), container.getUsername(), container.getPassword()); + Statement statement = connection.createStatement()) { + + // AND powerless user should be gone + try (ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM mcmmo_users WHERE user = '" + powerlessPlayer.getName() + "'")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)).isZero(); + } + + // AND powered user should still exist + try (ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM mcmmo_users WHERE user = '" + poweredPlayer.getName() + "'")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)).isEqualTo(1); + } + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // Missing user / fallback behavior + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - loadPlayerProfile(missing name) returns empty profile with zero skills") + @MethodSource("dbFlavors") + void whenLoadingMissingUserByNameShouldReturnEmptyProfileWithZeroSkills(DbFlavor flavor) { + // GIVEN an empty database + SQLDatabaseManager databaseManager = createManagerFor(flavor); + truncateAllCoreTables(flavor); + + String ghostName = "ghost_" + flavor.name().toLowerCase(); + + try { + // WHEN loading a profile by a missing username + PlayerProfile profile = databaseManager.loadPlayerProfile(ghostName); + + // THEN profile should not be null, and all skill levels should be zero + assertThat(profile).isNotNull(); + for (PrimarySkillType type : PrimarySkillType.values()) { + assertThat(profile.getSkillLevel(type)) + .as("Expected skill level 0 for %s on missing user profile", type) + .isZero(); + } + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // Mob health HUD reset + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - resetMobHealthSettings sets mobhealthbar to default for all users") + @MethodSource("dbFlavors") + void whenResettingMobHealthSettingsShouldResetAllHudRowsToDefault(DbFlavor flavor) throws Exception { + // GIVEN multiple users with non-default mobhealthbar values + SQLDatabaseManager databaseManager = createManagerFor(flavor); + truncateAllCoreTables(flavor); + + databaseManager.newUser("hudguy1_" + flavor.name().toLowerCase(), UUID.randomUUID()); + databaseManager.newUser("hudguy2_" + flavor.name().toLowerCase(), UUID.randomUUID()); + + try (Connection connection = databaseManager.getConnection(SQLDatabaseManager.PoolIdentifier.MISC); + Statement statement = connection.createStatement()) { + + statement.executeUpdate("UPDATE mcmmo_huds SET mobhealthbar = 'SOMETHING_ELSE'"); + } + + try { + // WHEN resetMobHealthSettings is invoked + databaseManager.resetMobHealthSettings(); + + // THEN all HUD rows should have the default mobhealthbar type + try (Connection connection = databaseManager.getConnection(SQLDatabaseManager.PoolIdentifier.MISC); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT DISTINCT mobhealthbar FROM mcmmo_huds")) { + + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getString(1)).isEqualTo(MobHealthbarType.HEARTS.name()); + assertThat(resultSet.next()) + .as("Only one distinct mobhealthbar value should remain") + .isFalse(); + } + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // loadPlayerProfile by name / UUID / Player + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - loadPlayerProfile(name)") + @MethodSource("dbFlavors") + void whenLoadingByNameShouldReturnMatchingProfile(DbFlavor flavor) { + // GIVEN a persisted user + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + String playerName = "nossr50_" + flavor.name().toLowerCase() + "_byName"; + UUID uuid = UUID.randomUUID(); + + try { + PlayerProfile createdProfile = databaseManager.newUser(playerName, uuid); + assertThat(createdProfile.isLoaded()).isTrue(); + + // WHEN loading by name + PlayerProfile loadedProfile = databaseManager.loadPlayerProfile(playerName); + + // THEN the loaded profile should match the persisted data + assertThat(loadedProfile.isLoaded()).isTrue(); + assertThat(loadedProfile.getPlayerName()).isEqualTo(playerName); + assertThat(loadedProfile.getUniqueId()).isEqualTo(uuid); + } finally { + databaseManager.onDisable(); + } + } + + @ParameterizedTest(name = "{0} - loadPlayerProfile(uuid)") + @MethodSource("dbFlavors") + void whenLoadingByUuidShouldReturnMatchingProfileAndUnknownUuidShouldReturnUnloadedProfile(DbFlavor flavor) { + // GIVEN a persisted user + truncateAllCoreTables(flavor); + final SQLDatabaseManager databaseManager = createManagerFor(flavor); + + final String playerName = "nossr50_" + flavor.name().toLowerCase() + "_byUuid"; + final UUID uuid = UUID.randomUUID(); + + try { + PlayerProfile newlyCreatedUser = databaseManager.newUser(playerName, uuid); + databaseManager.saveUser(newlyCreatedUser); + + // WHEN loading by the correct UUID + PlayerProfile loadedProfile = databaseManager.loadPlayerProfile(uuid, "tEmPnAmE"); + + // THEN the profile should be loaded and match + assertThat(loadedProfile.isLoaded()).isTrue(); + assertThat(loadedProfile.getPlayerName()).isEqualTo("tEmPnAmE"); + assertThat(loadedProfile.getUniqueId()).isEqualTo(uuid); + + // AND loading by an unknown UUID should return an unloaded profile + PlayerProfile unknownProfile = databaseManager.loadPlayerProfile(UUID.randomUUID()); + assertThat(unknownProfile.isLoaded()).isFalse(); + } finally { + databaseManager.onDisable(); + } + } + + @ParameterizedTest(name = "{0} - loadPlayerProfile(Player) updates username") + @MethodSource("dbFlavors") + void whenLoadingByPlayerShouldUpdateUsernameForExistingUuid(DbFlavor flavor) { + // GIVEN a user persisted under an original name + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + String originalName = "nossr50_original_" + flavor.name().toLowerCase(); + UUID uuid = UUID.randomUUID(); + + try { + databaseManager.newUser(originalName, uuid); + + // AND a Player with the same UUID but an updated name + String updatedName = "nossr50_updated_" + flavor.name().toLowerCase(); + Player player = Mockito.mock(Player.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.getName()).thenReturn(updatedName); + + // WHEN loading via Player + PlayerProfile updatedProfile = databaseManager.loadPlayerProfile(player); + + // THEN the profile should reflect the new name + assertThat(updatedProfile.isLoaded()).isTrue(); + assertThat(updatedProfile.getPlayerName()).isEqualTo(updatedName); + assertThat(updatedProfile.getUniqueId()).isEqualTo(uuid); + + // AND loading by new name should work + PlayerProfile byNewName = databaseManager.loadPlayerProfile(updatedName); + assertThat(byNewName.isLoaded()).isTrue(); + assertThat(byNewName.getPlayerName()).isEqualTo(updatedName); + assertThat(byNewName.getUniqueId()).isEqualTo(uuid); + + // AND loading by old name should now return an unloaded profile + PlayerProfile byOldName = databaseManager.loadPlayerProfile(originalName); + assertThat(byOldName.isLoaded()).isFalse(); + } finally { + databaseManager.onDisable(); + } + } + + @ParameterizedTest(name = "{0} - loadPlayerProfile(name) data not found") + @MethodSource("dbFlavors") + void whenLoadingNonExistentPlayerByNameShouldReturnUnloadedProfile(DbFlavor flavor) { + // GIVEN an empty database + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + try { + // WHEN loading a non-existent player by name + PlayerProfile profile = databaseManager.loadPlayerProfile("nonexistent_" + flavor.name().toLowerCase()); + + // THEN the profile should not be loaded + assertThat(profile.isLoaded()).isFalse(); + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // removeUser + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - removeUser") + @MethodSource("dbFlavors") + void whenRemovingUserShouldDeleteOnlySpecifiedUser(DbFlavor flavor) { + // GIVEN two persisted users + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + String keepName = "keepme_" + flavor.name().toLowerCase(); + UUID keepUuid = UUID.randomUUID(); + databaseManager.newUser(keepName, keepUuid); + + String deleteName = "deleteme_" + flavor.name().toLowerCase(); + UUID deleteUuid = UUID.randomUUID(); + databaseManager.newUser(deleteName, deleteUuid); + + try { + // AND both users exist + assertThat(databaseManager.loadPlayerProfile(keepUuid).isLoaded()).isTrue(); + assertThat(databaseManager.loadPlayerProfile(deleteUuid).isLoaded()).isTrue(); + + // WHEN removing the delete user + boolean firstRemovalSucceeded = databaseManager.removeUser(deleteName, deleteUuid); + + // THEN the first removal should succeed and the user should be gone + assertThat(firstRemovalSucceeded).isTrue(); + assertThat(databaseManager.loadPlayerProfile(deleteUuid).isLoaded()).isFalse(); + + // AND a second removal should fail + boolean secondRemovalSucceeded = databaseManager.removeUser(deleteName, deleteUuid); + assertThat(secondRemovalSucceeded).isFalse(); + + // AND the keep user should still exist + assertThat(databaseManager.loadPlayerProfile(keepUuid).isLoaded()).isTrue(); + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // purgeOldUsers + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - purgeOldUsers") + @MethodSource("dbFlavors") + void whenPurgingOldUsersShouldRemoveOnlyOutdatedUsers(DbFlavor flavor) throws Exception { + // GIVEN one old user and one recent user + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + when(mcMMO.p.getPurgeTime()).thenReturn(10L); + + String oldName = "old_" + flavor.name().toLowerCase(); + UUID oldUuid = UUID.randomUUID(); + databaseManager.newUser(oldName, oldUuid); + + String recentName = "recent_" + flavor.name().toLowerCase(); + UUID recentUuid = UUID.randomUUID(); + databaseManager.newUser(recentName, recentUuid); + + try (Connection connection = databaseManager.getConnection(SQLDatabaseManager.PoolIdentifier.MISC); + Statement statement = connection.createStatement()) { + + statement.executeUpdate("UPDATE mcmmo_users SET lastlogin = 0 WHERE `user` = '" + oldName + "'"); + statement.executeUpdate( + "UPDATE mcmmo_users SET lastlogin = UNIX_TIMESTAMP() WHERE `user` = '" + recentName + "'"); + } + + try { + // WHEN purgeOldUsers is invoked + databaseManager.purgeOldUsers(); + + // THEN old user should be removed + PlayerProfile oldProfile = databaseManager.loadPlayerProfile(oldUuid); + assertThat(oldProfile.isLoaded()) + .as("Old user should have been purged") + .isFalse(); + + // AND recent user should remain + PlayerProfile recentProfile = databaseManager.loadPlayerProfile(recentUuid); + assertThat(recentProfile.isLoaded()) + .as("Recent user should remain") + .isTrue(); + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // readRank + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - readRank") + @MethodSource("dbFlavors") + void whenReadingRankShouldReturnExpectedPositions(DbFlavor flavor) { + // GIVEN two users with different levels + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + String rankGirlName = "rankGirl_" + flavor.name().toLowerCase(); + UUID rankGirlUuid = new UUID(1337L, 1337L); + + String rankBoyName = "rankBoy_" + flavor.name().toLowerCase(); + UUID rankBoyUuid = new UUID(7331L, 7331L); + + try { + databaseManager.newUser(rankGirlName, rankGirlUuid); + PlayerProfile girlProfile = databaseManager.loadPlayerProfile(rankGirlUuid); + for (PrimarySkillType type : PrimarySkillType.values()) { + if (SkillTools.isChildSkill(type)) { + continue; + } + girlProfile.modifySkill(type, 100); + } + assertThat(databaseManager.saveUser(girlProfile)).isTrue(); + + databaseManager.newUser(rankBoyName, rankBoyUuid); + PlayerProfile boyProfile = databaseManager.loadPlayerProfile(rankBoyUuid); + for (PrimarySkillType type : PrimarySkillType.values()) { + if (SkillTools.isChildSkill(type)) { + continue; + } + boyProfile.modifySkill(type, 10); + } + assertThat(databaseManager.saveUser(boyProfile)).isTrue(); + + // WHEN reading rank for both users + Map girlRanks = databaseManager.readRank(rankGirlName); + Map boyRanks = databaseManager.readRank(rankBoyName); + + // THEN girl should be rank 1, boy rank 2 for all non-child skills + for (PrimarySkillType type : PrimarySkillType.values()) { + if (SkillTools.isChildSkill(type)) { + assertThat(girlRanks.get(type)).isNull(); + assertThat(boyRanks.get(type)).isNull(); + } else { + assertThat(girlRanks.get(type)).isEqualTo(1); + assertThat(boyRanks.get(type)).isEqualTo(2); + } + } + + // AND total ranking (null key) should be 1 and 2 respectively + assertThat(girlRanks.get(null)).isEqualTo(1); + assertThat(boyRanks.get(null)).isEqualTo(2); + } finally { + databaseManager.onDisable(); + } + } + + @ParameterizedTest(name = "{0} - readLeaderboard(MINING) returns users in descending order") + @MethodSource("dbFlavors") + void whenReadingLeaderboardForMiningShouldReturnUsersOrderedBySkillDescending(DbFlavor flavor) throws Exception { + // GIVEN + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + String topPlayerName = "leader_top_" + flavor.name().toLowerCase(); + UUID topUuid = UUID.randomUUID(); + databaseManager.newUser(topPlayerName, topUuid); + + String lowerPlayerName = "leader_low_" + flavor.name().toLowerCase(); + UUID lowerUuid = UUID.randomUUID(); + databaseManager.newUser(lowerPlayerName, lowerUuid); + + PlayerProfile topProfile = databaseManager.loadPlayerProfile(topUuid); + PlayerProfile lowerProfile = databaseManager.loadPlayerProfile(lowerUuid); + + // GIVEN – mining levels: top > low + topProfile.modifySkill(PrimarySkillType.MINING, 200); + lowerProfile.modifySkill(PrimarySkillType.MINING, 50); + + assertThat(databaseManager.saveUser(topProfile)).isTrue(); + assertThat(databaseManager.saveUser(lowerProfile)).isTrue(); + + // WHEN + List miningStats = + databaseManager.readLeaderboard(PrimarySkillType.MINING, 1, 10); + + // THEN + assertThat(miningStats) + .extracting(PlayerStat::playerName) + .containsExactly(topPlayerName, lowerPlayerName); + + assertThat(miningStats) + .extracting(PlayerStat::value) + .containsExactly(200, 50); + + databaseManager.onDisable(); + } + + @ParameterizedTest(name = "{0} - readLeaderboard(null) uses total column") + @MethodSource("dbFlavors") + void whenReadingLeaderboardForTotalShouldUseTotalColumn(DbFlavor flavor) throws Exception { + // GIVEN + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + String topPlayerName = "leader_total_top_" + flavor.name().toLowerCase(); + UUID topUuid = UUID.randomUUID(); + databaseManager.newUser(topPlayerName, topUuid); + + String lowerPlayerName = "leader_total_low_" + flavor.name().toLowerCase(); + UUID lowerUuid = UUID.randomUUID(); + databaseManager.newUser(lowerPlayerName, lowerUuid); + + PlayerProfile topProfile = databaseManager.loadPlayerProfile(topUuid); + PlayerProfile lowerProfile = databaseManager.loadPlayerProfile(lowerUuid); + + // GIVEN – only MINING changed, but total is recomputed in updateSkills() + topProfile.modifySkill(PrimarySkillType.MINING, 300); + lowerProfile.modifySkill(PrimarySkillType.MINING, 100); + + assertThat(databaseManager.saveUser(topProfile)).isTrue(); + assertThat(databaseManager.saveUser(lowerProfile)).isTrue(); + + // WHEN – null skill → ALL_QUERY_VERSION ("total") + List totalStats = databaseManager.readLeaderboard(null, 1, 10); + + // THEN + assertThat(totalStats) + .extracting(PlayerStat::playerName) + .containsExactly(topPlayerName, lowerPlayerName); + + assertThat(totalStats) + .extracting(PlayerStat::value) + .containsExactly(300, 100); + + databaseManager.onDisable(); + } + + @ParameterizedTest(name = "{0} - readLeaderboard(child skill) throws InvalidSkillException") + @MethodSource("dbFlavors") + void whenReadingLeaderboardForChildSkillShouldThrowInvalidSkillException(DbFlavor flavor) { + // GIVEN + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + // WHEN / THEN + assertThatThrownBy(() -> + databaseManager.readLeaderboard(PrimarySkillType.SALVAGE, 1, 10)) + .isInstanceOf(InvalidSkillException.class) + .hasMessageContaining("child skills do not have leaderboards"); + + databaseManager.onDisable(); + } + + + // ------------------------------------------------------------------------ + // getDatabaseType + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - getDatabaseType") + @MethodSource("dbFlavors") + void whenGettingDatabaseTypeShouldReturnSql(DbFlavor flavor) { + // GIVEN a database manager + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + try { + // WHEN retrieving the database type + DatabaseType databaseType = databaseManager.getDatabaseType(); + + // THEN it should be SQL + assertThat(databaseType).isEqualTo(DatabaseType.SQL); + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // Helpers for legacy schema tests + // ------------------------------------------------------------------------ + + /** + * Simulate an "old" schema where the spears columns do not exist yet. + * We drop any existing mcMMO tables and recreate them without spears. + */ + private void prepareLegacySchemaWithoutSpears(DbFlavor flavor) throws SQLException { + JdbcDatabaseContainer container = containerFor(flavor); + + try (Connection connection = DriverManager.getConnection( + container.getJdbcUrl(), container.getUsername(), container.getPassword()); + Statement statement = connection.createStatement()) { + + // Clean slate + statement.executeUpdate("DROP TABLE IF EXISTS mcmmo_cooldowns"); + statement.executeUpdate("DROP TABLE IF EXISTS mcmmo_experience"); + statement.executeUpdate("DROP TABLE IF EXISTS mcmmo_skills"); + statement.executeUpdate("DROP TABLE IF EXISTS mcmmo_huds"); + statement.executeUpdate("DROP TABLE IF EXISTS mcmmo_users"); + + // Minimal users table + statement.executeUpdate( + "CREATE TABLE mcmmo_users (" + + "id INT AUTO_INCREMENT PRIMARY KEY," + + "user VARCHAR(40) NOT NULL," + + "uuid VARCHAR(36)," + + "lastlogin BIGINT NOT NULL" + + ")" + ); + + // Minimal huds table + statement.executeUpdate( + "CREATE TABLE mcmmo_huds (" + + "user_id INT(10) UNSIGNED NOT NULL," + + "mobhealthbar VARCHAR(50) NOT NULL DEFAULT 'HEARTS'," + + "scoreboardtips INT(10) NOT NULL DEFAULT 0," + + "PRIMARY KEY (user_id)" + + ")" + ); + + // LEGACY skills table: everything up to maces, BUT NO spears + statement.executeUpdate( + "CREATE TABLE mcmmo_skills (" + + "user_id INT(10) UNSIGNED NOT NULL," + + "taming INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "mining INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "woodcutting INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "repair INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "unarmed INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "herbalism INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "excavation INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "archery INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "swords INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "axes INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "acrobatics INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "fishing INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "alchemy INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "crossbows INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "tridents INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "maces INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "total INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "PRIMARY KEY (user_id)" + + ")" + ); + + // LEGACY experience table: everything up to maces, BUT NO spears + statement.executeUpdate( + "CREATE TABLE mcmmo_experience (" + + "user_id INT(10) UNSIGNED NOT NULL," + + "taming INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "mining INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "woodcutting INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "repair INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "unarmed INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "herbalism INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "excavation INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "archery INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "swords INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "axes INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "acrobatics INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "fishing INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "alchemy INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "crossbows INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "tridents INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "maces INT(10) UNSIGNED NOT NULL DEFAULT 0," + + "PRIMARY KEY (user_id)" + + ")" + ); + + // LEGACY cooldowns table: everything up to maces, BUT NO spears + statement.executeUpdate( + "CREATE TABLE mcmmo_cooldowns (" + + "user_id INT(10) UNSIGNED NOT NULL," + + "taming INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "mining INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "woodcutting INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "repair INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "unarmed INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "herbalism INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "excavation INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "archery INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "swords INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "axes INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "acrobatics INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "blast_mining INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "chimaera_wing INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "crossbows INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "tridents INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "maces INT(32) UNSIGNED NOT NULL DEFAULT 0," + + "PRIMARY KEY (user_id)" + + ")" + ); + } + } + + private boolean columnExists(DbFlavor flavor, String tableName, String columnName) + throws SQLException { + JdbcDatabaseContainer container = containerFor(flavor); + try (Connection connection = DriverManager.getConnection( + container.getJdbcUrl(), container.getUsername(), container.getPassword()); + ResultSet resultSet = connection.getMetaData().getColumns(null, null, tableName, columnName)) { + return resultSet.next(); + } + } +}