Browse Source

Spears (wip pt 2)

nossr50 2 weeks ago
parent
commit
02ef9fad1d

+ 1 - 0
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)

+ 96 - 13
pom.xml

@@ -1,4 +1,6 @@
-<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
     <modelVersion>4.0.0</modelVersion>
     <groupId>com.gmail.nossr50.mcMMO</groupId>
     <artifactId>mcMMO</artifactId>
@@ -13,7 +15,7 @@
     </scm>
 
     <properties>
-<!--        <spigot.version>1.19-R0.1-SNAPSHOT</spigot.version>-->
+        <!--        <spigot.version>1.19-R0.1-SNAPSHOT</spigot.version>-->
         <spigot.version>1.21.10-R0.1-SNAPSHOT</spigot.version>
         <kyori.adventure.version>4.23.0</kyori.adventure.version>
         <kyori.adventure.platform.version>4.4.1-SNAPSHOT</kyori.adventure.platform.version>
@@ -182,11 +184,13 @@
                         </relocation>
                         <relocation>
                             <pattern>co.aikar.commands</pattern>
-                            <shadedPattern>com.gmail.nossr50.mcmmo.acf</shadedPattern> <!-- Replace this -->
+                            <shadedPattern>com.gmail.nossr50.mcmmo.acf
+                            </shadedPattern> <!-- Replace this -->
                         </relocation>
                         <relocation>
                             <pattern>co.aikar.locales</pattern>
-                            <shadedPattern>com.gmail.nossr50.mcmmo.locales</shadedPattern> <!-- Replace this -->
+                            <shadedPattern>com.gmail.nossr50.mcmmo.locales
+                            </shadedPattern> <!-- Replace this -->
                         </relocation>
                         <relocation>
                             <pattern>org.apache.commons.logging</pattern>
@@ -194,7 +198,8 @@
                         </relocation>
                         <relocation>
                             <pattern>org.apache.juli</pattern>
-                            <shadedPattern>com.gmail.nossr50.mcmmo.database.tomcat.juli</shadedPattern>
+                            <shadedPattern>com.gmail.nossr50.mcmmo.database.tomcat.juli
+                            </shadedPattern>
                         </relocation>
                         <relocation>
                             <pattern>org.apache.tomcat</pattern>
@@ -390,11 +395,11 @@
             <version>3.0.2</version>
             <scope>compile</scope>
         </dependency>
-<!--        <dependency>-->
-<!--            <groupId>io.papermc.paper</groupId>-->
-<!--            <artifactId>paper-api</artifactId>-->
-<!--            <version>1.21.8-R0.1-SNAPSHOT</version>-->
-<!--        </dependency>-->
+        <!--        <dependency>-->
+        <!--            <groupId>io.papermc.paper</groupId>-->
+        <!--            <artifactId>paper-api</artifactId>-->
+        <!--            <version>1.21.8-R0.1-SNAPSHOT</version>-->
+        <!--        </dependency>-->
         <dependency>
             <groupId>org.spigotmc</groupId>
             <artifactId>spigot-api</artifactId>
@@ -431,10 +436,76 @@
                 </exclusion>
             </exclusions>
         </dependency>
+        <!-- JUnit 5 -->
         <dependency>
             <groupId>org.junit.jupiter</groupId>
             <artifactId>junit-jupiter</artifactId>
-            <version>5.11.0-M2</version>
+            <version>5.11.0</version>
+            <scope>test</scope>
+        </dependency>
+        <!-- Testcontainers core -->
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>testcontainers</artifactId>
+            <version>2.0.2</version>
+            <scope>test</scope>
+        </dependency>
+        <!-- Testcontainers JUnit Jupiter integration -->
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>testcontainers-junit-jupiter</artifactId>
+            <version>2.0.2</version>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- Log4j core for tests -->
+        <dependency>
+            <groupId>org.apache.logging.log4j</groupId>
+            <artifactId>log4j-core</artifactId>
+            <version>2.25.2</version>
+            <scope>test</scope>
+        </dependency>
+        <!-- Log4j API -->
+        <!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api -->
+        <dependency>
+            <groupId>org.apache.logging.log4j</groupId>
+            <artifactId>log4j-api</artifactId>
+            <version>2.25.2</version>
+        </dependency>
+        <!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-slf4j-impl -->
+        <dependency>
+            <groupId>org.apache.logging.log4j</groupId>
+            <artifactId>log4j-slf4j-impl</artifactId>
+            <version>2.25.2</version>
+            <scope>test</scope>
+        </dependency>
+        <!-- MySQL Testcontainers module -->
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>testcontainers-mysql</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <!-- MariaDB Testcontainers module -->
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>testcontainers-mariadb</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <!-- MySQL JDBC driver -->
+        <!-- https://mvnrepository.com/artifact/com.mysql/mysql-connector-j -->
+        <dependency>
+            <groupId>com.mysql</groupId>
+            <artifactId>mysql-connector-j</artifactId>
+            <version>9.5.0</version>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- MariaDB JDBC driver -->
+        <!-- https://mvnrepository.com/artifact/org.mariadb.jdbc/mariadb-java-client -->
+        <dependency>
+            <groupId>org.mariadb.jdbc</groupId>
+            <artifactId>mariadb-java-client</artifactId>
+            <version>3.5.6</version>
             <scope>test</scope>
         </dependency>
         <dependency>
@@ -452,7 +523,7 @@
         <dependency>
             <groupId>org.apache.tomcat</groupId>
             <artifactId>tomcat-jdbc</artifactId>
-            <version>10.1.24</version>
+            <version>11.0.14</version>
             <scope>compile</scope>
         </dependency>
         <dependency>
@@ -463,7 +534,8 @@
         <dependency>
             <groupId>com.google.guava</groupId>
             <artifactId>guava</artifactId>
-            <version>33.2.0-jre</version> <!-- At this time Spigot is including 29.0 Guava classes that we are using -->
+            <version>33.2.0-jre
+            </version> <!-- At this time Spigot is including 29.0 Guava classes that we are using -->
             <scope>compile</scope>
         </dependency>
         <dependency>
@@ -473,4 +545,15 @@
             <scope>compile</scope>
         </dependency>
     </dependencies>
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.testcontainers</groupId>
+                <artifactId>testcontainers-bom</artifactId>
+                <version>2.0.2</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
 </project>

+ 2 - 2
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(

+ 2 - 1
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<String> 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))));

+ 2 - 2
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);
     }
 

+ 21 - 18
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]
+                                : "<unknown username>";
+                        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;
 

File diff suppressed because it is too large
+ 662 - 496
src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java


+ 44 - 61
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);
     }

+ 1 - 1
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;
     }

+ 18 - 4
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&ethis 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}

+ 1282 - 245
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<mcMMO> 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<mcMMO> 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<DbFlavor> 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<String, UUID> 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<PrimarySkillType, Integer> girlRanks = databaseManager.readRank(rankGirlName);
+            Map<PrimarySkillType, Integer> 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<PlayerStat> 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<PlayerStat> 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();
+        }
+    }
+}

Some files were not shown because too many files changed in this diff