Selaa lähdekoodia

Spears update (#5236)

Spears update
Robert Alan Chapton 1 päivä sitten
vanhempi
sitoutus
b15365e978
55 muutettua tiedostoa jossa 4728 lisäystä ja 1812 poistoa
  1. 30 0
      Changelog.txt
  2. 98 15
      pom.xml
  3. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/AcrobaticsCommand.java
  4. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/AlchemyCommand.java
  5. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/ArcheryCommand.java
  6. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/AxesCommand.java
  7. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/CrossbowsCommand.java
  8. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/ExcavationCommand.java
  9. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/FishingCommand.java
  10. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/HerbalismCommand.java
  11. 3 3
      src/main/java/com/gmail/nossr50/commands/skills/MacesCommand.java
  12. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/MiningCommand.java
  13. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/RepairCommand.java
  14. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/SalvageCommand.java
  15. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/SmeltingCommand.java
  16. 85 0
      src/main/java/com/gmail/nossr50/commands/skills/SpearsCommand.java
  17. 3 9
      src/main/java/com/gmail/nossr50/commands/skills/SwordsCommand.java
  18. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/TamingCommand.java
  19. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/TridentsCommand.java
  20. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/UnarmedCommand.java
  21. 1 1
      src/main/java/com/gmail/nossr50/commands/skills/WoodcuttingCommand.java
  22. 13 2
      src/main/java/com/gmail/nossr50/config/AdvancedConfig.java
  23. 0 4
      src/main/java/com/gmail/nossr50/config/GeneralConfig.java
  24. 2 2
      src/main/java/com/gmail/nossr50/database/DatabaseManagerFactory.java
  25. 8 6
      src/main/java/com/gmail/nossr50/database/FlatFileDataProcessor.java
  26. 322 475
      src/main/java/com/gmail/nossr50/database/FlatFileDatabaseManager.java
  27. 571 428
      src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java
  28. 8 7
      src/main/java/com/gmail/nossr50/database/flatfile/FlatFileDataUtil.java
  29. 0 1
      src/main/java/com/gmail/nossr50/datatypes/database/UpgradeType.java
  30. 44 61
      src/main/java/com/gmail/nossr50/datatypes/player/McMMOPlayer.java
  31. 1 1
      src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java
  32. 1 0
      src/main/java/com/gmail/nossr50/datatypes/skills/PrimarySkillType.java
  33. 5 0
      src/main/java/com/gmail/nossr50/datatypes/skills/SubSkillType.java
  34. 9 2
      src/main/java/com/gmail/nossr50/datatypes/skills/SuperAbilityType.java
  35. 10 1
      src/main/java/com/gmail/nossr50/listeners/PlayerListener.java
  36. 121 0
      src/main/java/com/gmail/nossr50/skills/spears/SpearsManager.java
  37. 40 14
      src/main/java/com/gmail/nossr50/util/ItemUtils.java
  38. 1 0
      src/main/java/com/gmail/nossr50/util/LogUtils.java
  39. 26 4
      src/main/java/com/gmail/nossr50/util/MaterialMapStore.java
  40. 2 0
      src/main/java/com/gmail/nossr50/util/commands/CommandRegistrationManager.java
  41. 56 0
      src/main/java/com/gmail/nossr50/util/skills/CombatUtils.java
  42. 198 128
      src/main/java/com/gmail/nossr50/util/skills/SkillTools.java
  43. 18 0
      src/main/java/com/gmail/nossr50/util/text/TextComponentFactory.java
  44. 16 1
      src/main/resources/advanced.yml
  45. 6 0
      src/main/resources/config.yml
  46. 11 0
      src/main/resources/experience.yml
  47. 26 4
      src/main/resources/locale/locale_en_US.properties
  48. 185 112
      src/main/resources/plugin.yml
  49. 24 0
      src/main/resources/repair.vanilla.yml
  50. 28 0
      src/main/resources/salvage.vanilla.yml
  51. 66 0
      src/main/resources/skillranks.yml
  52. 640 268
      src/test/java/com/gmail/nossr50/database/FlatFileDatabaseManagerTest.java
  53. 1661 245
      src/test/java/com/gmail/nossr50/database/SQLDatabaseManagerTest.java
  54. 371 0
      src/test/java/com/gmail/nossr50/util/skills/SkillToolsTest.java
  55. 3 3
      src/test/resources/healthydb.users

+ 30 - 0
Changelog.txt

@@ -1,3 +1,33 @@
+Version 2.2.046
+    Added Spears combat skill
+    Added Spears to repair.vanilla.yml and salvage.vanilla.yml (see notes)
+    Added various permissions related to Spears
+    Added /spears skill command
+    Added Nautilus to taming XP in experience.yml
+    Added Camel_Husk to taming XP in experience.yml
+    Added Camel_Husk to combat XP in experience.yml
+    Added Parched to combat XP in experience.yml
+    Fixed bug where converting from SQL to FlatFile would not copy data for tridents, crossbows, maces, or spears
+    (Codebase) Added docker-based unit tests for SQL databases (see notes)
+    (Codebase) Large refactor to both SQLDatabaseManager and FlatFileDatabaseManager
+    (Codebase) Database related errors are now more descriptive and have had their logging improved
+
+    NOTES:
+    This update had a lot of changes behind the scenes, please report any bugs you find to our GitHub issues page!
+    You will need to manually update repair.vanilla.yml and salvage.vanilla.yml to get support for Spears, or...
+    If you want to update salvage/repair configs the easy way, you simply can delete these config files to have mcMMO regenerate them with the new entries.
+    If you don't want to delete them, you can find the default values for these config files in the defaults folder at plugins\mcMMO\defaults after running this mcMMO update at least once.
+    You can use this default file to copy paste if you please.
+    Docker is ONLY required for developers compiling mcMMO from source code and ONLY for running SQL-related unit tests.
+    mcMMO itself does NOT require Docker to run, and servers using prebuilt releases are completely unaffected.
+    New SQL database unit tests use Testcontainers to spin up temporary MySQL/MariaDB instances for testing purposes.
+    These containers are created at test time and are never used at runtime.
+    If you compile mcMMO locally and do not have Docker installed, SQL-related unit tests may fail.
+    In this case, you can safely compile with -DskipTests to skip unit tests entirely.
+    Skipping tests has no impact on mcMMO functionality when running on a server.
+    Known Issues:
+    I ran into an issue where having a spear in the offhand while the main hand is empty causes attacks to be incorrectly classified as unarmed. This allows unarmed abilities to apply to spear damage. As a temporary measure, I’ve disabled unarmed skills from applying to combat when a spear is equipped in the offhand while I investigate a more robust solution.
+
 Version 2.2.045
     Green Thumb now replants some crops it was failing to replant before (see notes)
     Green Thumb now replants harvested plants faster

+ 98 - 15
pom.xml

@@ -1,8 +1,10 @@
-<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>
-    <version>2.2.046-SNAPSHOT</version>
+    <version>2.2.046</version>
     <name>mcMMO</name>
     <url>https://github.com/mcMMO-Dev/mcMMO</url>
     <scm>
@@ -13,8 +15,8 @@
     </scm>
 
     <properties>
-<!--        <spigot.version>1.19-R0.1-SNAPSHOT</spigot.version>-->
-        <spigot.version>1.21.10-R0.1-SNAPSHOT</spigot.version>
+        <!--        <spigot.version>1.19-R0.1-SNAPSHOT</spigot.version>-->
+        <spigot.version>1.21.11-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>
         <kyori.option.version>1.1.0</kyori.option.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>
@@ -385,11 +390,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>
@@ -426,10 +431,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>
@@ -447,7 +518,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>
@@ -458,7 +529,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>
@@ -468,4 +540,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>

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/AcrobaticsCommand.java

@@ -73,7 +73,7 @@ public class AcrobaticsCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.ACROBATICS);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/AlchemyCommand.java

@@ -92,7 +92,7 @@ public class AlchemyCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.ALCHEMY);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/ArcheryCommand.java

@@ -93,7 +93,7 @@ public class ArcheryCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.ARCHERY);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/AxesCommand.java

@@ -119,7 +119,7 @@ public class AxesCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         final List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.AXES);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/CrossbowsCommand.java

@@ -68,7 +68,7 @@ public class CrossbowsCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.CROSSBOWS);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/ExcavationCommand.java

@@ -71,7 +71,7 @@ public class ExcavationCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.EXCAVATION);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/FishingCommand.java

@@ -185,7 +185,7 @@ public class FishingCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.FISHING);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/HerbalismCommand.java

@@ -187,7 +187,7 @@ public class HerbalismCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.HERBALISM);
 
         return textComponents;

+ 3 - 3
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(
@@ -77,7 +77,7 @@ public class MacesCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.MACES);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/MiningCommand.java

@@ -144,7 +144,7 @@ public class MiningCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.MINING);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/RepairCommand.java

@@ -134,7 +134,7 @@ public class RepairCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.REPAIR);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/SalvageCommand.java

@@ -72,7 +72,7 @@ public class SalvageCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.SALVAGE);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/SmeltingCommand.java

@@ -97,7 +97,7 @@ public class SmeltingCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.SMELTING);
 
         return textComponents;

+ 85 - 0
src/main/java/com/gmail/nossr50/commands/skills/SpearsCommand.java

@@ -0,0 +1,85 @@
+package com.gmail.nossr50.commands.skills;
+
+
+import static com.gmail.nossr50.datatypes.skills.SubSkillType.SPEARS_MOMENTUM;
+import static com.gmail.nossr50.datatypes.skills.SubSkillType.SPEARS_SPEARS_LIMIT_BREAK;
+import static com.gmail.nossr50.datatypes.skills.SubSkillType.SPEARS_SPEAR_MASTERY;
+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;
+import com.gmail.nossr50.locale.LocaleLoader;
+import com.gmail.nossr50.mcMMO;
+import com.gmail.nossr50.skills.spears.SpearsManager;
+import com.gmail.nossr50.util.player.UserManager;
+import com.gmail.nossr50.util.skills.CombatUtils;
+import com.gmail.nossr50.util.skills.RankUtils;
+import com.gmail.nossr50.util.skills.SkillUtils;
+import java.util.ArrayList;
+import java.util.List;
+import net.kyori.adventure.text.Component;
+import org.bukkit.entity.Player;
+
+public class SpearsCommand extends SkillCommand {
+
+    public SpearsCommand() {
+        super(PrimarySkillType.SPEARS);
+    }
+
+    String momentumChanceToApply, momentumChanceToApplyLucky, momentumDuration;
+
+    @Override
+    protected void dataCalculations(Player player, float skillValue) {
+        if (SkillUtils.canUseSubskill(player, SPEARS_MOMENTUM)) {
+            int momentumRank = RankUtils.getRank(player, SPEARS_MOMENTUM);
+            momentumDuration = String.valueOf(
+                    SpearsManager.getMomentumTickDuration(momentumRank) / 20.0D);
+            momentumChanceToApply =
+                    mcMMO.p.getAdvancedConfig().getMomentumChanceToApplyOnHit(momentumRank) + "%";
+            momentumChanceToApplyLucky = String.valueOf(
+                    mcMMO.p.getAdvancedConfig().getMomentumChanceToApplyOnHit(momentumRank) * 1.33);
+        }
+    }
+
+    @Override
+    protected void permissionsCheck(Player player) {
+    }
+
+    @Override
+    protected List<String> statsDisplay(Player player, float skillValue, boolean hasEndurance,
+            boolean isLucky) {
+        final SpearsManager spearsManager = UserManager.getPlayer(player).getSpearsManager();
+        final double spearMasteryBonusDmg = spearsManager.getSpearMasteryBonusDamage();
+
+        List<String> messages = new ArrayList<>();
+
+        if (canUseSubskill(player, SPEARS_SPEARS_LIMIT_BREAK)) {
+            messages.add(getStatMessage(SPEARS_SPEARS_LIMIT_BREAK,
+                    String.valueOf(CombatUtils.getLimitBreakDamageAgainstQuality(player,
+                            SPEARS_SPEARS_LIMIT_BREAK, 1000))));
+        }
+
+        if (canUseSubskill(player, SPEARS_SPEAR_MASTERY)) {
+            messages.add(getStatMessage(SPEARS_SPEAR_MASTERY,
+                    String.valueOf(spearMasteryBonusDmg)));
+        }
+
+        if (SkillUtils.canUseSubskill(player, SPEARS_MOMENTUM)) {
+            messages.add(getStatMessage(SPEARS_MOMENTUM, momentumChanceToApply)
+                    + (isLucky ? LocaleLoader.getString("Perks.Lucky.Bonus",
+                    momentumChanceToApplyLucky) : ""));
+            messages.add(getStatMessage(true, true, SPEARS_MOMENTUM, momentumDuration));
+        }
+
+        return messages;
+    }
+
+    @Override
+    protected List<Component> getTextComponents(Player player) {
+        List<Component> textComponents = new ArrayList<>();
+
+        appendSubSkillTextComponents(player, textComponents, PrimarySkillType.SPEARS);
+
+        return textComponents;
+    }
+}

+ 3 - 9
src/main/java/com/gmail/nossr50/commands/skills/SwordsCommand.java

@@ -22,8 +22,8 @@ public class SwordsCommand extends SkillCommand {
     private String serratedStrikesLengthEndurance;
 
     private String rupturePureTickDamageAgainstPlayers, rupturePureTickDamageAgainstMobs,
-            ruptureExplosionDamageAgainstPlayers, ruptureExplosionDamageAgainstMobs,
-            ruptureLengthSecondsAgainstPlayers, ruptureLengthSecondsAgainstMobs, ruptureChanceToApply, ruptureChanceToApplyLucky;
+            ruptureLengthSecondsAgainstPlayers, ruptureLengthSecondsAgainstMobs,
+            ruptureChanceToApply, ruptureChanceToApplyLucky;
 
     private boolean canCounter;
     private boolean canSerratedStrike;
@@ -56,11 +56,6 @@ public class SwordsCommand extends SkillCommand {
             rupturePureTickDamageAgainstMobs = String.valueOf(
                     mcMMO.p.getAdvancedConfig().getRuptureTickDamage(false, ruptureRank));
 
-            ruptureExplosionDamageAgainstPlayers = String.valueOf(
-                    mcMMO.p.getAdvancedConfig().getRuptureExplosionDamage(true, ruptureRank));
-            ruptureExplosionDamageAgainstMobs = String.valueOf(
-                    mcMMO.p.getAdvancedConfig().getRuptureExplosionDamage(false, ruptureRank));
-
             ruptureChanceToApply =
                     mcMMO.p.getAdvancedConfig().getRuptureChanceToApplyOnHit(ruptureRank) + "%";
             ruptureChanceToApplyLucky = String.valueOf(
@@ -105,7 +100,6 @@ public class SwordsCommand extends SkillCommand {
 
             messages.add(LocaleLoader.getString("Swords.SubSkill.Rupture.Stat.TickDamage",
                     rupturePureTickDamageAgainstPlayers, rupturePureTickDamageAgainstMobs));
-//            messages.add(LocaleLoader.getString("Swords.SubSkill.Rupture.Stat.ExplosionDamage", ruptureExplosionDamageAgainstPlayers, ruptureExplosionDamageAgainstMobs));
 
             messages.add(LocaleLoader.getString("Swords.Combat.Rupture.Note.Update.One"));
         }
@@ -134,7 +128,7 @@ public class SwordsCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.SWORDS);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/TamingCommand.java

@@ -115,7 +115,7 @@ public class TamingCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents, this.skill);
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents, this.skill);
 
         return textComponents;
     }

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/TridentsCommand.java

@@ -50,7 +50,7 @@ public class TridentsCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.TRIDENTS);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/UnarmedCommand.java

@@ -136,7 +136,7 @@ public class UnarmedCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.UNARMED);
 
         return textComponents;

+ 1 - 1
src/main/java/com/gmail/nossr50/commands/skills/WoodcuttingCommand.java

@@ -123,7 +123,7 @@ public class WoodcuttingCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.WOODCUTTING);
 
         return textComponents;

+ 13 - 2
src/main/java/com/gmail/nossr50/config/AdvancedConfig.java

@@ -11,6 +11,7 @@ import net.md_5.bungee.api.ChatColor;
 
 public class AdvancedConfig extends BukkitConfig {
     int[] defaultCrippleValues = new int[]{10, 15, 20, 25};
+    int[] defaultMomentumValues = new int[]{5, 10, 15, 20, 25, 30, 35, 40, 45, 50};
 
     public AdvancedConfig(File dataFolder) {
         super("advanced.yml", dataFolder);
@@ -884,7 +885,17 @@ public class AdvancedConfig extends BukkitConfig {
 
     /* MACES */
     public double getCrippleChanceToApplyOnHit(int rank) {
-        String root = "Skills.Maces.Cripple.Chance_To_Apply_On_Hit.Rank_";
-        return config.getDouble(root + rank, defaultCrippleValues[rank - 1]);
+        return config.getDouble("Skills.Maces.Cripple.Chance_To_Apply_On_Hit.Rank_" + rank,
+                defaultCrippleValues[rank - 1]);
+    }
+
+    /* SPEARS */
+    public double getMomentumChanceToApplyOnHit(int rank) {
+        return config.getDouble("Skills.Spears.Momentum.Chance_To_Apply_On_Hit.Rank_" + rank,
+                defaultMomentumValues[rank - 1]);
+    }
+
+    public double getSpearMasteryRankDamageMultiplier() {
+        return config.getDouble("Skills.Spears.SpearMastery.Rank_Damage_Multiplier", 0.4D);
     }
 }

+ 0 - 4
src/main/java/com/gmail/nossr50/config/GeneralConfig.java

@@ -424,10 +424,6 @@ public class GeneralConfig extends BukkitConfig {
         return config.getBoolean("MySQL.Server.SSL", true);
     }
 
-    public boolean getMySQLDebug() {
-        return config.getBoolean("MySQL.Debug", false);
-    }
-
     public boolean getMySQLPublicKeyRetrieval() {
         return config.getBoolean("MySQL.Server.allowPublicKeyRetrieval", true);
     }

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

+ 8 - 6
src/main/java/com/gmail/nossr50/database/FlatFileDataProcessor.java

@@ -9,6 +9,7 @@ import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_GREEN_
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_MACES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SERRATED_STRIKES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SKULL_SPLITTER;
+import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SPEARS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SUPER_BREAKER;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SUPER_SHOTGUN;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_TREE_FELLER;
@@ -25,6 +26,7 @@ import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_HERBALISM;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_MACES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_MINING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_REPAIR;
+import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_SPEARS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_SWORDS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TAMING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TRIDENTS;
@@ -45,6 +47,7 @@ import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_HERBALIS
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_MACES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_MINING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_REPAIR;
+import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_SPEARS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_SWORDS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TAMING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TRIDENTS;
@@ -318,27 +321,26 @@ public class FlatFileDataProcessor {
             throws IndexOutOfBoundsException {
         return switch (dataIndex) {
             case USERNAME_INDEX ->
-                    ExpectedType.STRING; //Assumption: Used to be for something, no longer used
-            //Assumption: Used to be for something, no longer used
-            //Assumption: Used to be used for something, no longer used
+                    ExpectedType.STRING;
             //Assumption: Used to be used for something, no longer used
             case 2, 3, 23, 33, HEALTHBAR, LEGACY_LAST_LOGIN -> ExpectedType.IGNORED;
             case SKILLS_MINING, SKILLS_REPAIR, SKILLS_UNARMED, SKILLS_HERBALISM, SKILLS_EXCAVATION,
                  SKILLS_ARCHERY,
                  SKILLS_SWORDS, SKILLS_AXES, SKILLS_WOODCUTTING, SKILLS_ACROBATICS, SKILLS_TAMING,
                  SKILLS_FISHING,
-                 SKILLS_ALCHEMY, SKILLS_CROSSBOWS, SKILLS_TRIDENTS, SKILLS_MACES, COOLDOWN_BERSERK,
+                 SKILLS_ALCHEMY, SKILLS_CROSSBOWS, SKILLS_TRIDENTS, SKILLS_MACES, SKILLS_SPEARS,
+                 COOLDOWN_BERSERK,
                  COOLDOWN_GIGA_DRILL_BREAKER, COOLDOWN_TREE_FELLER, COOLDOWN_GREEN_TERRA,
                  COOLDOWN_SERRATED_STRIKES,
                  COOLDOWN_SKULL_SPLITTER, COOLDOWN_SUPER_BREAKER, COOLDOWN_BLAST_MINING,
                  SCOREBOARD_TIPS,
                  COOLDOWN_CHIMAERA_WING, COOLDOWN_SUPER_SHOTGUN, COOLDOWN_TRIDENTS,
-                 COOLDOWN_ARCHERY, COOLDOWN_MACES -> ExpectedType.INTEGER;
+                 COOLDOWN_ARCHERY, COOLDOWN_MACES, COOLDOWN_SPEARS -> ExpectedType.INTEGER;
             case EXP_MINING, EXP_WOODCUTTING, EXP_REPAIR, EXP_UNARMED, EXP_HERBALISM,
                  EXP_EXCAVATION, EXP_ARCHERY,
                  EXP_SWORDS, EXP_AXES, EXP_ACROBATICS, EXP_TAMING, EXP_FISHING, EXP_ALCHEMY,
                  EXP_CROSSBOWS,
-                 EXP_TRIDENTS, EXP_MACES -> ExpectedType.FLOAT;
+                 EXP_TRIDENTS, EXP_MACES, EXP_SPEARS -> ExpectedType.FLOAT;
             case UUID_INDEX -> ExpectedType.UUID;
             case OVERHAUL_LAST_LOGIN -> ExpectedType.LONG;
             default -> throw new IndexOutOfBoundsException();

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 322 - 475
src/main/java/com/gmail/nossr50/database/FlatFileDatabaseManager.java


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 571 - 428
src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java


+ 8 - 7
src/main/java/com/gmail/nossr50/database/flatfile/FlatFileDataUtil.java

@@ -9,6 +9,7 @@ import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_GREEN_
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_MACES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SERRATED_STRIKES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SKULL_SPLITTER;
+import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SPEARS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SUPER_BREAKER;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SUPER_SHOTGUN;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_TREE_FELLER;
@@ -24,6 +25,7 @@ import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_HERBALISM;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_MACES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_MINING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_REPAIR;
+import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_SPEARS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_SWORDS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TAMING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TRIDENTS;
@@ -45,6 +47,7 @@ import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_HERBALIS
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_MACES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_MINING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_REPAIR;
+import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_SPEARS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_SWORDS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TAMING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TRIDENTS;
@@ -114,18 +117,16 @@ public class FlatFileDataUtil {
             throws IndexOutOfBoundsException {
         //TODO: Add UUID recovery? Might not even be worth it.
         return switch (index) {
+            //We'll keep using this value for legacy compatibility reasons (not sure if needed but don't care)
             case USERNAME_INDEX ->
-                    LEGACY_INVALID_OLD_USERNAME; //We'll keep using this value for legacy compatibility reasons (not sure if needed but don't care)
-            //Assumption: Used to be for something, no longer used
-            //Assumption: Used to be for something, no longer used
-            //Assumption: Used to be used for something, no longer used
+                    LEGACY_INVALID_OLD_USERNAME;
             //Assumption: Used to be used for something, no longer used
             case 2, 3, 23, 33, LEGACY_LAST_LOGIN, HEALTHBAR -> "IGNORED";
             case SKILLS_MINING, SKILLS_REPAIR, SKILLS_UNARMED, SKILLS_HERBALISM, SKILLS_EXCAVATION,
                  SKILLS_ARCHERY,
                  SKILLS_SWORDS, SKILLS_AXES, SKILLS_WOODCUTTING, SKILLS_ACROBATICS, SKILLS_TAMING,
                  SKILLS_FISHING,
-                 SKILLS_ALCHEMY, SKILLS_CROSSBOWS, SKILLS_TRIDENTS, SKILLS_MACES ->
+                 SKILLS_ALCHEMY, SKILLS_CROSSBOWS, SKILLS_TRIDENTS, SKILLS_MACES, SKILLS_SPEARS ->
                     String.valueOf(startingLevel);
             case OVERHAUL_LAST_LOGIN -> String.valueOf(-1L);
             case COOLDOWN_BERSERK, COOLDOWN_GIGA_DRILL_BREAKER, COOLDOWN_TREE_FELLER,
@@ -133,12 +134,12 @@ public class FlatFileDataUtil {
                  COOLDOWN_SERRATED_STRIKES, COOLDOWN_SKULL_SPLITTER, COOLDOWN_SUPER_BREAKER,
                  COOLDOWN_BLAST_MINING,
                  COOLDOWN_SUPER_SHOTGUN, COOLDOWN_TRIDENTS, COOLDOWN_ARCHERY, COOLDOWN_MACES,
-                 SCOREBOARD_TIPS, COOLDOWN_CHIMAERA_WING,
+                 COOLDOWN_SPEARS, SCOREBOARD_TIPS, COOLDOWN_CHIMAERA_WING,
                  EXP_MINING, EXP_WOODCUTTING, EXP_REPAIR, EXP_UNARMED, EXP_HERBALISM,
                  EXP_EXCAVATION, EXP_ARCHERY,
                  EXP_SWORDS, EXP_AXES, EXP_ACROBATICS, EXP_TAMING, EXP_FISHING, EXP_ALCHEMY,
                  EXP_CROSSBOWS,
-                 EXP_TRIDENTS, EXP_MACES -> "0";
+                 EXP_TRIDENTS, EXP_MACES, EXP_SPEARS -> "0";
             case UUID_INDEX ->
                     throw new IndexOutOfBoundsException(); //TODO: Add UUID recovery? Might not even be worth it.
             default -> throw new IndexOutOfBoundsException();

+ 0 - 1
src/main/java/com/gmail/nossr50/datatypes/database/UpgradeType.java

@@ -3,7 +3,6 @@ package com.gmail.nossr50.datatypes.database;
 public enum UpgradeType {
     ADD_FISHING,
     ADD_BLAST_MINING_COOLDOWN,
-    ADD_SQL_INDEXES,
     ADD_MOB_HEALTHBARS,
     DROP_SQL_PARTY_NAMES,
     DROP_SPOUT,

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

+ 1 - 0
src/main/java/com/gmail/nossr50/datatypes/skills/PrimarySkillType.java

@@ -24,6 +24,7 @@ public enum PrimarySkillType {
     REPAIR,
     SALVAGE,
     SMELTING,
+    SPEARS,
     SWORDS,
     TAMING,
     TRIDENTS,

+ 5 - 0
src/main/java/com/gmail/nossr50/datatypes/skills/SubSkillType.java

@@ -83,6 +83,11 @@ public enum SubSkillType {
     SMELTING_SECOND_SMELT,
     SMELTING_UNDERSTANDING_THE_ART(8),
 
+    /* Spears */
+    SPEARS_SPEARS_LIMIT_BREAK(10),
+    SPEARS_MOMENTUM(10),
+    SPEARS_SPEAR_MASTERY(8),
+
     /* Swords */
     SWORDS_COUNTER_ATTACK(1),
     SWORDS_RUPTURE(4),

+ 9 - 2
src/main/java/com/gmail/nossr50/datatypes/skills/SuperAbilityType.java

@@ -93,6 +93,13 @@ public enum SuperAbilityType {
             "Placeholder",
             "Placeholder",
             "Placeholder"),
+    SPEARS_SUPER_ABILITY(
+            "Placeholder",
+            "Placeholder",
+            "Placeholder",
+            "Placeholder",
+            "Placeholder",
+            "Placeholder"),
 
     /**
      * Has cooldown - but has to share a skill with Super Breaker, so needs special treatment
@@ -216,8 +223,8 @@ public enum SuperAbilityType {
             case SUPER_BREAKER -> Permissions.superBreaker(player);
             case TREE_FELLER -> Permissions.treeFeller(player);
             // TODO: once implemented, return permissions for the following abilities
-            case EXPLOSIVE_SHOT, TRIDENTS_SUPER_ABILITY, SUPER_SHOTGUN, MACES_SUPER_ABILITY ->
-                    false;
+            case EXPLOSIVE_SHOT, TRIDENTS_SUPER_ABILITY, SUPER_SHOTGUN, MACES_SUPER_ABILITY,
+                 SPEARS_SUPER_ABILITY -> false;
         };
     }
 

+ 10 - 1
src/main/java/com/gmail/nossr50/listeners/PlayerListener.java

@@ -168,6 +168,7 @@ public class PlayerListener implements Listener {
         if (WorldBlacklist.isWorldBlacklisted(event.getEntity().getWorld())) {
             return;
         }
+
         // world guard main flag check
         if (WorldGuardUtils.isWorldGuardLoaded() && !WorldGuardManager.getInstance()
                 .hasMainFlag((Player) event.getEntity())) {
@@ -342,8 +343,8 @@ public class PlayerListener implements Listener {
         FishingManager fishingManager = UserManager.getPlayer(player).getFishingManager();
 
         switch (event.getState()) {
+            // CAUGHT_FISH happens for any item caught (including junk and treasure)
             case CAUGHT_FISH:
-                //TODO Update to new API once available! Waiting for case CAUGHT_TREASURE
                 if (event.getCaught() != null) {
                     Item fishingCatch = (Item) event.getCaught();
 
@@ -675,6 +676,10 @@ public class PlayerListener implements Listener {
      */
     @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
     public void onPlayerInteractLowest(PlayerInteractEvent event) {
+        if (event.getAction() == Action.PHYSICAL) {
+            return;
+        }
+
         /* WORLD BLACKLIST CHECK */
         if (WorldBlacklist.isWorldBlacklisted(event.getPlayer().getWorld())) {
             return;
@@ -817,6 +822,10 @@ public class PlayerListener implements Listener {
      */
     @EventHandler(priority = EventPriority.MONITOR)
     public void onPlayerInteractMonitor(PlayerInteractEvent event) {
+        if (event.getAction() == Action.PHYSICAL) {
+            return;
+        }
+
         /* WORLD BLACKLIST CHECK */
         if (WorldBlacklist.isWorldBlacklisted(event.getPlayer().getWorld())) {
             return;

+ 121 - 0
src/main/java/com/gmail/nossr50/skills/spears/SpearsManager.java

@@ -0,0 +1,121 @@
+package com.gmail.nossr50.skills.spears;
+
+import static com.gmail.nossr50.util.random.ProbabilityUtil.isStaticSkillRNGSuccessful;
+import static com.gmail.nossr50.util.skills.RankUtils.getRank;
+
+import com.gmail.nossr50.datatypes.interactions.NotificationType;
+import com.gmail.nossr50.datatypes.player.McMMOPlayer;
+import com.gmail.nossr50.datatypes.skills.PrimarySkillType;
+import com.gmail.nossr50.datatypes.skills.SubSkillType;
+import com.gmail.nossr50.mcMMO;
+import com.gmail.nossr50.skills.SkillManager;
+import com.gmail.nossr50.util.Permissions;
+import com.gmail.nossr50.util.player.NotificationManager;
+import java.util.Locale;
+import org.bukkit.NamespacedKey;
+import org.bukkit.Registry;
+import org.bukkit.potion.PotionEffect;
+import org.bukkit.potion.PotionEffectType;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+public class SpearsManager extends SkillManager {
+    private static @Nullable PotionEffectType swiftnessEffectType;
+    public SpearsManager(McMMOPlayer mmoPlayer) {
+        super(mmoPlayer, PrimarySkillType.SPEARS);
+    }
+
+    private static @Nullable PotionEffectType mockSpigotMatch(@NotNull String input) {
+        // Replicates match() behaviour for older versions lacking this API
+        final String filtered = input.toLowerCase(Locale.ROOT).replaceAll("\\s+", "_");
+        final NamespacedKey namespacedKey = NamespacedKey.fromString(filtered);
+        return (namespacedKey != null) ? Registry.EFFECT.get(namespacedKey) : null;
+    }
+
+    /**
+     * Process Momentum activation.
+     */
+    public void potentiallyApplyMomentum() {
+        // Lazy initialized to avoid some backwards compatibility issues
+        if (swiftnessEffectType == null) {
+            if (mockSpigotMatch("speed") == null) {
+                mcMMO.p.getLogger().severe("Unable to find the Speed PotionEffectType, " +
+                        "mcMMO will not function properly.");
+                throw new IllegalStateException("Unable to find the Speed PotionEffectType!");
+            } else {
+                swiftnessEffectType = mockSpigotMatch("speed");
+            }
+        }
+
+        if (!canMomentumBeApplied()) {
+            return;
+        }
+
+        int momentumRank = getRank(getPlayer(), SubSkillType.SPEARS_MOMENTUM);
+        // Chance to activate on hit is influence by the CD
+        double momentumOdds = (mcMMO.p.getAdvancedConfig().getMomentumChanceToApplyOnHit(momentumRank)
+                * Math.min(mmoPlayer.getAttackStrength(), 1.0D));
+
+        if (isStaticSkillRNGSuccessful(PrimarySkillType.SPEARS, mmoPlayer, momentumOdds)) {
+            if (mmoPlayer.useChatNotifications()) {
+                NotificationManager.sendPlayerInformation(mmoPlayer.getPlayer(),
+                        NotificationType.SUBSKILL_MESSAGE, "Spears.SubSkill.Momentum.Activated");
+            }
+
+            // Momentum is success, Momentum the target
+            getPlayer().addPotionEffect(swiftnessEffectType.createEffect(
+                    getMomentumTickDuration(momentumRank),
+                    getMomentumStrength()));
+            // TODO: Consider adding an effect here
+            // ParticleEffectUtils.playMomentumEffect(target);
+        }
+    }
+
+    public static int getMomentumTickDuration(int momentumRank) {
+        return 20 * (momentumRank * 2);
+    }
+
+    public static int getMomentumStrength() {
+        return 2;
+    }
+
+    private boolean canMomentumBeApplied() {
+        // TODO: Potentially it should overwrite the effect if we are providing a stronger one
+        if (swiftnessEffectType == null) {
+            return false;
+        }
+        final PotionEffect currentlyAppliedPotion = getPlayer()
+                .getPotionEffect(swiftnessEffectType);
+
+        if (currentlyAppliedPotion != null) {
+            if (isCurrentPotionEffectStronger(currentlyAppliedPotion)) {
+                return false;
+            }
+        }
+
+        if (!Permissions.canUseSubSkill(mmoPlayer.getPlayer(), SubSkillType.SPEARS_MOMENTUM)) {
+            return false;
+        }
+
+        return true;
+    }
+
+    private boolean isCurrentPotionEffectStronger(@NotNull PotionEffect potionEffect) {
+        if (potionEffect.getAmplifier() > getMomentumStrength()) {
+            return true;
+        }
+
+        if (potionEffect.getDuration() > getMomentumTickDuration(getRank(getPlayer(),
+                SubSkillType.SPEARS_MOMENTUM))) {
+            return true;
+        }
+
+        return false;
+    }
+
+    public double getSpearMasteryBonusDamage() {
+        return mcMMO.p.getAdvancedConfig().getSpearMasteryRankDamageMultiplier()
+                * getRank(getPlayer(), SubSkillType.SPEARS_SPEAR_MASTERY);
+    }
+
+}

+ 40 - 14
src/main/java/com/gmail/nossr50/util/ItemUtils.java

@@ -166,20 +166,6 @@ public final class ItemUtils {
         }
     }
 
-    // TODO: Unit tests
-    public static boolean isCrossbow(@NotNull ItemStack item) {
-        return mcMMO.getMaterialMapStore().isCrossbow(item.getType().getKey().getKey());
-    }
-
-    // TODO: Unit tests
-    public static boolean isTrident(@NotNull ItemStack item) {
-        return mcMMO.getMaterialMapStore().isTrident(item.getType().getKey().getKey());
-    }
-
-    public static boolean isMace(@NotNull ItemStack item) {
-        return mcMMO.getMaterialMapStore().isMace(item.getType().getKey().getKey());
-    }
-
     public static boolean hasItemInEitherHand(@NotNull Player player, Material material) {
         return player.getInventory().getItemInMainHand().getType() == material
                 || player.getInventory().getItemInOffHand().getType() == material;
@@ -276,6 +262,46 @@ public final class ItemUtils {
         return null;
     }
 
+    /**
+     * Checks if the item is a crossbow.
+     *
+     * @param item Item to check
+     * @return true if the item is a crossbow, false otherwise
+     */
+    public static boolean isCrossbow(@NotNull ItemStack item) {
+        return mcMMO.getMaterialMapStore().isCrossbow(item.getType().getKey().getKey());
+    }
+
+    /**
+     * Checks if the item is a trident.
+     *
+     * @param item Item to check
+     * @return true if the item is a trident, false otherwise
+     */
+    public static boolean isTrident(@NotNull ItemStack item) {
+        return mcMMO.getMaterialMapStore().isTrident(item.getType().getKey().getKey());
+    }
+
+    /**
+     * Checks if the item is a mace.
+     *
+     * @param item Item to check
+     * @return true if the item is a mace, false otherwise
+     */
+    public static boolean isMace(@NotNull ItemStack item) {
+        return mcMMO.getMaterialMapStore().isMace(item.getType().getKey().getKey());
+    }
+
+    /**
+     * Checks if the item is a spear.
+     * @param item Item to check
+     *
+     * @return true if the item is a spear, false otherwise
+     */
+    public static boolean isSpear(@NotNull ItemStack item) {
+        return mcMMO.getMaterialMapStore().isSpear(item.getType().getKey().getKey());
+    }
+
     /**
      * Checks if the item is a sword.
      *

+ 1 - 0
src/main/java/com/gmail/nossr50/util/LogUtils.java

@@ -8,6 +8,7 @@ public class LogUtils {
     public static final String DEBUG_STR = "[D] ";
 
     public static void debug(@NotNull Logger logger, @NotNull String message) {
+        // Messages here will get filtered based on config settings via LogFilter
         logger.info(DEBUG_STR + message);
     }
 }

+ 26 - 4
src/main/java/com/gmail/nossr50/util/MaterialMapStore.java

@@ -51,9 +51,10 @@ public class MaterialMapStore {
     private final @NotNull HashSet<String> tridents;
     private final @NotNull HashSet<String> bows;
     private final @NotNull HashSet<String> crossbows;
-    private final @NotNull HashSet<String> tools;
-    private final @NotNull HashSet<String> enchantables;
     private final @NotNull HashSet<String> maces;
+    private final @NotNull HashSet<String> spears;
+    private final @NotNull HashSet<String> enchantables;
+    private final @NotNull HashSet<String> tools;
 
     private final @NotNull HashSet<String> ores;
     private final @NotNull HashSet<String> intendedToolPickAxe;
@@ -95,15 +96,15 @@ public class MaterialMapStore {
         crossbows = new HashSet<>();
         stringTools = new HashSet<>();
         prismarineTools = new HashSet<>();
-        tools = new HashSet<>();
-
         swords = new HashSet<>();
         axes = new HashSet<>();
         pickAxes = new HashSet<>();
         shovels = new HashSet<>();
         hoes = new HashSet<>();
         tridents = new HashSet<>();
+        spears = new HashSet<>();
         maces = new HashSet<>();
+        tools = new HashSet<>();
 
         enchantables = new HashSet<>();
 
@@ -459,6 +460,7 @@ public class MaterialMapStore {
         enchantables.addAll(bows);
         enchantables.addAll(crossbows);
         enchantables.addAll(maces);
+        enchantables.addAll(spears);
 
         enchantables.add("shears");
         enchantables.add("fishing_rod");
@@ -484,6 +486,7 @@ public class MaterialMapStore {
         fillShovels();
         fillTridents();
         fillMaces();
+        fillSpears();
         fillStringTools();
         fillPrismarineTools();
         fillBows();
@@ -502,6 +505,7 @@ public class MaterialMapStore {
         tools.addAll(bows);
         tools.addAll(crossbows);
         tools.addAll(maces);
+        tools.addAll(spears);
     }
 
     private void fillBows() {
@@ -527,6 +531,16 @@ public class MaterialMapStore {
         maces.add("mace");
     }
 
+    private void fillSpears() {
+        spears.add("wooden_spear");
+        spears.add("stone_spear");
+        spears.add("copper_spear");
+        spears.add("iron_spear");
+        spears.add("golden_spear");
+        spears.add("diamond_spear");
+        spears.add("netherite_spear");
+    }
+
     private void fillTridents() {
         tridents.add("trident");
     }
@@ -874,6 +888,14 @@ public class MaterialMapStore {
         return maces.contains(id);
     }
 
+    public boolean isSpear(@NotNull Material material) {
+        return isSpear(material.getKey().getKey());
+    }
+
+    public boolean isSpear(@NotNull String id) {
+        return spears.contains(id);
+    }
+
     public boolean isLeatherArmor(@NotNull Material material) {
         return isLeatherArmor(material.getKey().getKey());
     }

+ 2 - 0
src/main/java/com/gmail/nossr50/util/commands/CommandRegistrationManager.java

@@ -41,6 +41,7 @@ import com.gmail.nossr50.commands.skills.MmoInfoCommand;
 import com.gmail.nossr50.commands.skills.RepairCommand;
 import com.gmail.nossr50.commands.skills.SalvageCommand;
 import com.gmail.nossr50.commands.skills.SmeltingCommand;
+import com.gmail.nossr50.commands.skills.SpearsCommand;
 import com.gmail.nossr50.commands.skills.SwordsCommand;
 import com.gmail.nossr50.commands.skills.TamingCommand;
 import com.gmail.nossr50.commands.skills.TridentsCommand;
@@ -101,6 +102,7 @@ public final class CommandRegistrationManager {
                 case REPAIR -> command.setExecutor(new RepairCommand());
                 case SALVAGE -> command.setExecutor(new SalvageCommand());
                 case SMELTING -> command.setExecutor(new SmeltingCommand());
+                case SPEARS -> command.setExecutor(new SpearsCommand());
                 case SWORDS -> command.setExecutor(new SwordsCommand());
                 case TAMING -> command.setExecutor(new TamingCommand());
                 case TRIDENTS -> command.setExecutor(new TridentsCommand());

+ 56 - 0
src/main/java/com/gmail/nossr50/util/skills/CombatUtils.java

@@ -3,6 +3,7 @@ package com.gmail.nossr50.util.skills;
 import static com.gmail.nossr50.datatypes.experience.XPGainReason.PVP;
 import static com.gmail.nossr50.util.AttributeMapper.MAPPED_MOVEMENT_SPEED;
 import static com.gmail.nossr50.util.MobMetadataUtils.hasMobFlag;
+import static com.gmail.nossr50.util.Permissions.canUseSubSkill;
 import static com.gmail.nossr50.util.skills.ProjectileUtils.isCrossbowProjectile;
 
 import com.gmail.nossr50.config.experience.ExperienceConfig;
@@ -19,6 +20,7 @@ import com.gmail.nossr50.skills.acrobatics.AcrobaticsManager;
 import com.gmail.nossr50.skills.archery.ArcheryManager;
 import com.gmail.nossr50.skills.axes.AxesManager;
 import com.gmail.nossr50.skills.maces.MacesManager;
+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;
@@ -331,6 +333,46 @@ public final class CombatUtils {
         printFinalDamageDebug(player, event, mmoPlayer);
     }
 
+    private static void processSpearsCombat(@NotNull LivingEntity target,
+            @NotNull Player player,
+            @NotNull EntityDamageByEntityEvent event) {
+        if (event.getCause() == DamageCause.THORNS) {
+            return;
+        }
+
+        double boostedDamage = event.getDamage();
+
+        final McMMOPlayer mmoPlayer = UserManager.getPlayer(player);
+
+        //Make sure the profiles been loaded
+        if (mmoPlayer == null) {
+            return;
+        }
+
+        final SpearsManager spearsManager = mmoPlayer.getSpearsManager();
+
+        if (canUseSubSkill(player, SubSkillType.SPEARS_SPEAR_MASTERY)) {
+            boostedDamage += spearsManager.getSpearMasteryBonusDamage()
+                    * mmoPlayer.getAttackStrength();
+        }
+
+        // Apply Limit Break DMG
+        if (canUseLimitBreak(player, target, SubSkillType.SPEARS_SPEARS_LIMIT_BREAK)) {
+            boostedDamage += (getLimitBreakDamage(
+                    player, target, SubSkillType.SPEARS_SPEARS_LIMIT_BREAK)
+                    * mmoPlayer.getAttackStrength());
+        }
+
+
+        event.setDamage(boostedDamage);
+
+        // Apply any non-damage effects here
+        spearsManager.potentiallyApplyMomentum();
+
+        processCombatXP(mmoPlayer, target, PrimarySkillType.SPEARS);
+        printFinalDamageDebug(player, event, mmoPlayer);
+    }
+
     private static void processAxeCombat(@NotNull LivingEntity target, @NotNull Player player,
             @NotNull EntityDamageByEntityEvent event) {
         if (event.getCause() == DamageCause.THORNS) {
@@ -391,6 +433,11 @@ public final class CombatUtils {
 
         double boostedDamage = event.getDamage();
 
+        // TODO: Temporary hack to avoid unintended spear / unarmed interactions
+        if (ItemUtils.isSpear(player.getInventory().getItemInOffHand())) {
+            return;
+        }
+
         final McMMOPlayer mmoPlayer = UserManager.getPlayer(player);
 
         //Make sure the profiles been loaded
@@ -642,6 +689,15 @@ public final class CombatUtils {
                         .doesPlayerHaveSkillPermission(player, PrimarySkillType.MACES)) {
                     processMacesCombat(target, player, event);
                 }
+            } else if (ItemUtils.isSpear(heldItem)) {
+                if (!mcMMO.p.getSkillTools()
+                        .canCombatSkillsTrigger(PrimarySkillType.SPEARS, target)) {
+                    return;
+                }
+                if (mcMMO.p.getSkillTools()
+                        .doesPlayerHaveSkillPermission(player, PrimarySkillType.SPEARS)) {
+                    processSpearsCombat(target, player, event);
+                }
             }
         } else if (entityType == EntityType.WOLF) {
             Wolf wolf = (Wolf) painSource;

+ 198 - 128
src/main/java/com/gmail/nossr50/util/skills/SkillTools.java

@@ -1,6 +1,5 @@
 package com.gmail.nossr50.util.skills;
 
-import com.gmail.nossr50.api.exceptions.InvalidSkillException;
 import com.gmail.nossr50.config.experience.ExperienceConfig;
 import com.gmail.nossr50.datatypes.skills.PrimarySkillType;
 import com.gmail.nossr50.datatypes.skills.SubSkillType;
@@ -18,6 +17,7 @@ import java.util.Collections;
 import java.util.EnumMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import org.bukkit.entity.Entity;
 import org.bukkit.entity.Player;
@@ -27,15 +27,16 @@ import org.jetbrains.annotations.VisibleForTesting;
 
 public class SkillTools {
     private final mcMMO pluginRef;
+
     // TODO: Java has immutable types now, switch to those
     // TODO: Figure out which ones we don't need, this was copy pasted from a diff branch
     public final @NotNull ImmutableList<String> LOCALIZED_SKILL_NAMES;
     public final @NotNull ImmutableList<String> FORMATTED_SUBSKILL_NAMES;
     public final @NotNull ImmutableSet<String> EXACT_SUBSKILL_NAMES;
     public final @NotNull ImmutableList<PrimarySkillType> CHILD_SKILLS;
-    public final static @NotNull ImmutableList<PrimarySkillType> NON_CHILD_SKILLS;
-    public final static @NotNull ImmutableList<PrimarySkillType> SALVAGE_PARENTS;
-    public final static @NotNull ImmutableList<PrimarySkillType> SMELTING_PARENTS;
+    public static final @NotNull ImmutableList<PrimarySkillType> NON_CHILD_SKILLS;
+    public static final @NotNull ImmutableList<PrimarySkillType> SALVAGE_PARENTS;
+    public static final @NotNull ImmutableList<PrimarySkillType> SMELTING_PARENTS;
     public final @NotNull ImmutableList<PrimarySkillType> COMBAT_SKILLS;
     public final @NotNull ImmutableList<PrimarySkillType> GATHERING_SKILLS;
     public final @NotNull ImmutableList<PrimarySkillType> MISC_SKILLS;
@@ -44,73 +45,141 @@ public class SkillTools {
     private final @NotNull ImmutableMap<SuperAbilityType, PrimarySkillType> superAbilityParentRelationshipMap;
     private final @NotNull ImmutableMap<PrimarySkillType, Set<SubSkillType>> primarySkillChildrenMap;
 
-    // The map below is for the super abilities which require readying a tool, its everything except blast mining
     private final ImmutableMap<PrimarySkillType, SuperAbilityType> mainActivatedAbilityChildMap;
     private final ImmutableMap<PrimarySkillType, ToolType> primarySkillToolMap;
 
     static {
+        // Build NON_CHILD_SKILLS once from the enum values
         ArrayList<PrimarySkillType> tempNonChildSkills = new ArrayList<>();
         for (PrimarySkillType primarySkillType : PrimarySkillType.values()) {
-            if (primarySkillType != PrimarySkillType.SALVAGE
-                    && primarySkillType != PrimarySkillType.SMELTING) {
+            if (!isChildSkill(primarySkillType)) {
                 tempNonChildSkills.add(primarySkillType);
             }
         }
-
         NON_CHILD_SKILLS = ImmutableList.copyOf(tempNonChildSkills);
-        SALVAGE_PARENTS = ImmutableList.of(PrimarySkillType.REPAIR, PrimarySkillType.FISHING);
-        SMELTING_PARENTS = ImmutableList.of(PrimarySkillType.MINING, PrimarySkillType.REPAIR);
+
+        SALVAGE_PARENTS = ImmutableList.of(
+                PrimarySkillType.REPAIR,
+                PrimarySkillType.FISHING
+        );
+        SMELTING_PARENTS = ImmutableList.of(
+                PrimarySkillType.MINING,
+                PrimarySkillType.REPAIR
+        );
     }
 
-    public SkillTools(@NotNull mcMMO pluginRef) throws InvalidSkillException {
+    public SkillTools(@NotNull mcMMO pluginRef) {
         this.pluginRef = pluginRef;
 
         /*
          * Setup subskill -> parent relationship map
          */
-        EnumMap<SubSkillType, PrimarySkillType> tempSubParentMap = new EnumMap<>(
-                SubSkillType.class);
+        this.subSkillParentRelationshipMap = buildSubSkillParentMap();
+
+        /*
+         * Setup primary -> (collection) subskill map
+         */
+        this.primarySkillChildrenMap = buildPrimarySkillChildrenMap(subSkillParentRelationshipMap);
+
+        /*
+         * Setup primary -> tooltype map
+         */
+        this.primarySkillToolMap = buildPrimarySkillToolMap();
+
+        /*
+         * Setup ability -> primary map
+         * Setup primary -> ability map
+         */
+        var abilityMaps = buildSuperAbilityMaps();
+        this.superAbilityParentRelationshipMap = abilityMaps.superAbilityParentRelationshipMap();
+        this.mainActivatedAbilityChildMap = abilityMaps.mainActivatedAbilityChildMap();
+
+        /*
+         * Build child skill list
+         */
+        this.CHILD_SKILLS = buildChildSkills();
+
+        /*
+         * Build categorized skill lists
+         */
+        this.COMBAT_SKILLS = buildCombatSkills();
+        this.GATHERING_SKILLS = ImmutableList.of(
+                PrimarySkillType.EXCAVATION,
+                PrimarySkillType.FISHING,
+                PrimarySkillType.HERBALISM,
+                PrimarySkillType.MINING,
+                PrimarySkillType.WOODCUTTING
+        );
+        this.MISC_SKILLS = ImmutableList.of(
+                PrimarySkillType.ACROBATICS,
+                PrimarySkillType.ALCHEMY,
+                PrimarySkillType.REPAIR,
+                PrimarySkillType.SALVAGE,
+                PrimarySkillType.SMELTING
+        );
+
+        /*
+         * Build formatted/localized/etc string lists
+         */
+        this.LOCALIZED_SKILL_NAMES = ImmutableList.copyOf(buildLocalizedPrimarySkillNames());
+        this.FORMATTED_SUBSKILL_NAMES = ImmutableList.copyOf(buildFormattedSubSkillNameList());
+        this.EXACT_SUBSKILL_NAMES = ImmutableSet.copyOf(buildExactSubSkillNameList());
+    }
 
-        //Super hacky and disgusting
-        for (PrimarySkillType primarySkillType1 : PrimarySkillType.values()) {
-            for (SubSkillType subSkillType : SubSkillType.values()) {
-                String[] splitSubSkillName = subSkillType.toString().split("_");
+    @VisibleForTesting
+    @NotNull
+    ImmutableMap<SubSkillType, PrimarySkillType> buildSubSkillParentMap() {
+        EnumMap<SubSkillType, PrimarySkillType> tempSubParentMap =
+                new EnumMap<>(SubSkillType.class);
 
-                if (primarySkillType1.toString().equalsIgnoreCase(splitSubSkillName[0])) {
-                    //Parent Skill Found
-                    tempSubParentMap.put(subSkillType, primarySkillType1);
+        // SubSkillType names use a convention: <PRIMARY>_SOMETHING
+        for (SubSkillType subSkillType : SubSkillType.values()) {
+            String enumName = subSkillType.name();
+            int underscoreIndex = enumName.indexOf('_');
+            String parentPrefix = underscoreIndex == -1
+                    ? enumName
+                    : enumName.substring(0, underscoreIndex);
+
+            for (PrimarySkillType primarySkillType : PrimarySkillType.values()) {
+                if (primarySkillType.name().equalsIgnoreCase(parentPrefix)) {
+                    tempSubParentMap.put(subSkillType, primarySkillType);
+                    break;
                 }
             }
         }
 
-        subSkillParentRelationshipMap = ImmutableMap.copyOf(tempSubParentMap);
+        return ImmutableMap.copyOf(tempSubParentMap);
+    }
 
-        /*
-         * Setup primary -> (collection) subskill map
-         */
+    @VisibleForTesting
+    @NotNull
+    ImmutableMap<PrimarySkillType, Set<SubSkillType>> buildPrimarySkillChildrenMap(
+            ImmutableMap<SubSkillType, PrimarySkillType> subParentMap) {
 
-        EnumMap<PrimarySkillType, Set<SubSkillType>> tempPrimaryChildMap = new EnumMap<>(
-                PrimarySkillType.class);
+        EnumMap<PrimarySkillType, Set<SubSkillType>> tempPrimaryChildMap =
+                new EnumMap<>(PrimarySkillType.class);
 
-        //Init the empty Hash Sets
-        for (PrimarySkillType primarySkillType1 : PrimarySkillType.values()) {
-            tempPrimaryChildMap.put(primarySkillType1, new HashSet<>());
+        // Initialize empty sets
+        for (PrimarySkillType primarySkillType : PrimarySkillType.values()) {
+            tempPrimaryChildMap.put(primarySkillType, new HashSet<>());
         }
 
-        //Fill in the hash sets
+        // Fill sets
         for (SubSkillType subSkillType : SubSkillType.values()) {
-            PrimarySkillType parentSkill = subSkillParentRelationshipMap.get(subSkillType);
-
-            //Add this subskill as a child
-            tempPrimaryChildMap.get(parentSkill).add(subSkillType);
+            PrimarySkillType parentSkill = subParentMap.get(subSkillType);
+            if (parentSkill != null) {
+                tempPrimaryChildMap.get(parentSkill).add(subSkillType);
+            }
         }
 
-        primarySkillChildrenMap = ImmutableMap.copyOf(tempPrimaryChildMap);
+        return ImmutableMap.copyOf(tempPrimaryChildMap);
+    }
 
-        /*
-         * Setup primary -> tooltype map
-         */
-        EnumMap<PrimarySkillType, ToolType> tempToolMap = new EnumMap<>(PrimarySkillType.class);
+    @VisibleForTesting
+    @NotNull
+    ImmutableMap<PrimarySkillType, ToolType> buildPrimarySkillToolMap() {
+        EnumMap<PrimarySkillType, ToolType> tempToolMap =
+                new EnumMap<>(PrimarySkillType.class);
 
         tempToolMap.put(PrimarySkillType.AXES, ToolType.AXE);
         tempToolMap.put(PrimarySkillType.WOODCUTTING, ToolType.AXE);
@@ -120,56 +189,76 @@ public class SkillTools {
         tempToolMap.put(PrimarySkillType.HERBALISM, ToolType.HOE);
         tempToolMap.put(PrimarySkillType.MINING, ToolType.PICKAXE);
 
-        primarySkillToolMap = ImmutableMap.copyOf(tempToolMap);
+        return ImmutableMap.copyOf(tempToolMap);
+    }
 
-        /*
-         * Setup ability -> primary map
-         * Setup primary -> ability map
-         */
+    /**
+     * Holder for the two super ability maps, so we can build them in one pass.
+     */
+    @VisibleForTesting
+    record SuperAbilityMaps(
+            @NotNull ImmutableMap<SuperAbilityType, PrimarySkillType> superAbilityParentRelationshipMap,
+            @NotNull ImmutableMap<PrimarySkillType, SuperAbilityType> mainActivatedAbilityChildMap) {
+    }
 
-        EnumMap<SuperAbilityType, PrimarySkillType> tempAbilityParentRelationshipMap = new EnumMap<>(
-                SuperAbilityType.class);
-        EnumMap<PrimarySkillType, SuperAbilityType> tempMainActivatedAbilityChildMap = new EnumMap<>(
-                PrimarySkillType.class);
+    @VisibleForTesting
+    @NotNull
+    SuperAbilityMaps buildSuperAbilityMaps() {
+        final Map<SuperAbilityType, PrimarySkillType> tempAbilityParentRelationshipMap =
+                new EnumMap<>(SuperAbilityType.class);
+        final Map<PrimarySkillType, SuperAbilityType> tempMainActivatedAbilityChildMap =
+                new EnumMap<>(PrimarySkillType.class);
 
         for (SuperAbilityType superAbilityType : SuperAbilityType.values()) {
-            try {
-                PrimarySkillType parent = getSuperAbilityParent(superAbilityType);
-                tempAbilityParentRelationshipMap.put(superAbilityType, parent);
+            final PrimarySkillType parent = getSuperAbilityParent(superAbilityType);
+            tempAbilityParentRelationshipMap.put(superAbilityType, parent);
 
-                if (superAbilityType != SuperAbilityType.BLAST_MINING) {
-                    //This map is used only for abilities that have a tool readying phase, so blast mining is ignored
-                    tempMainActivatedAbilityChildMap.put(parent, superAbilityType);
-                }
-            } catch (InvalidSkillException e) {
-                e.printStackTrace();
+            // This map is used only for abilities that have a tool readying phase,
+            // so Blast Mining is ignored.
+            if (superAbilityType != SuperAbilityType.BLAST_MINING) {
+                tempMainActivatedAbilityChildMap.put(parent, superAbilityType);
             }
         }
 
-        superAbilityParentRelationshipMap = ImmutableMap.copyOf(tempAbilityParentRelationshipMap);
-        mainActivatedAbilityChildMap = ImmutableMap.copyOf(tempMainActivatedAbilityChildMap);
-
-        /*
-         * Build child skill and nonchild skill lists
-         */
+        return new SuperAbilityMaps(
+                ImmutableMap.copyOf(tempAbilityParentRelationshipMap),
+                ImmutableMap.copyOf(tempMainActivatedAbilityChildMap)
+        );
+    }
 
+    @VisibleForTesting
+    @NotNull
+    ImmutableList<PrimarySkillType> buildChildSkills() {
         List<PrimarySkillType> childSkills = new ArrayList<>();
-
         for (PrimarySkillType primarySkillType : PrimarySkillType.values()) {
             if (isChildSkill(primarySkillType)) {
                 childSkills.add(primarySkillType);
             }
         }
+        return ImmutableList.copyOf(childSkills);
+    }
 
-        CHILD_SKILLS = ImmutableList.copyOf(childSkills);
-
-        /*
-         * Build categorized skill lists
-         */
+    @VisibleForTesting
+    @NotNull
+    ImmutableList<PrimarySkillType> buildCombatSkills() {
+        var gameVersion = mcMMO.getCompatibilityManager().getMinecraftGameVersion();
 
-        // We are in a game version with Maces
-        if (mcMMO.getCompatibilityManager().getMinecraftGameVersion().isAtLeast(1, 21, 0)) {
-            COMBAT_SKILLS = ImmutableList.of(
+        if (gameVersion.isAtLeast(1, 21, 11)) {
+            // We are in a game version with Spears and Maces
+            return ImmutableList.of(
+                    PrimarySkillType.ARCHERY,
+                    PrimarySkillType.AXES,
+                    PrimarySkillType.CROSSBOWS,
+                    PrimarySkillType.MACES,
+                    PrimarySkillType.SWORDS,
+                    PrimarySkillType.SPEARS,
+                    PrimarySkillType.TAMING,
+                    PrimarySkillType.TRIDENTS,
+                    PrimarySkillType.UNARMED
+            );
+        } else if (gameVersion.isAtLeast(1, 21, 0)) {
+            // We are in a game version with Maces
+            return ImmutableList.of(
                     PrimarySkillType.ARCHERY,
                     PrimarySkillType.AXES,
                     PrimarySkillType.CROSSBOWS,
@@ -177,42 +266,23 @@ public class SkillTools {
                     PrimarySkillType.SWORDS,
                     PrimarySkillType.TAMING,
                     PrimarySkillType.TRIDENTS,
-                    PrimarySkillType.UNARMED);
+                    PrimarySkillType.UNARMED
+            );
         } else {
             // No Maces in this version
-            COMBAT_SKILLS = ImmutableList.of(
+            return ImmutableList.of(
                     PrimarySkillType.ARCHERY,
                     PrimarySkillType.AXES,
                     PrimarySkillType.CROSSBOWS,
                     PrimarySkillType.SWORDS,
                     PrimarySkillType.TAMING,
                     PrimarySkillType.TRIDENTS,
-                    PrimarySkillType.UNARMED);
+                    PrimarySkillType.UNARMED
+            );
         }
-        GATHERING_SKILLS = ImmutableList.of(
-                PrimarySkillType.EXCAVATION,
-                PrimarySkillType.FISHING,
-                PrimarySkillType.HERBALISM,
-                PrimarySkillType.MINING,
-                PrimarySkillType.WOODCUTTING);
-        MISC_SKILLS = ImmutableList.of(
-                PrimarySkillType.ACROBATICS,
-                PrimarySkillType.ALCHEMY,
-                PrimarySkillType.REPAIR,
-                PrimarySkillType.SALVAGE,
-                PrimarySkillType.SMELTING);
-
-        /*
-         * Build formatted/localized/etc string lists
-         */
-
-        LOCALIZED_SKILL_NAMES = ImmutableList.copyOf(buildLocalizedPrimarySkillNames());
-        FORMATTED_SUBSKILL_NAMES = ImmutableList.copyOf(buildFormattedSubSkillNameList());
-        EXACT_SUBSKILL_NAMES = ImmutableSet.copyOf(buildExactSubSkillNameList());
     }
 
-    private @NotNull PrimarySkillType getSuperAbilityParent(SuperAbilityType superAbilityType)
-            throws InvalidSkillException {
+    private @NotNull PrimarySkillType getSuperAbilityParent(SuperAbilityType superAbilityType) {
         return switch (superAbilityType) {
             case BERSERK -> PrimarySkillType.UNARMED;
             case GREEN_TERRA -> PrimarySkillType.HERBALISM;
@@ -225,11 +295,12 @@ public class SkillTools {
             case TRIDENTS_SUPER_ABILITY -> PrimarySkillType.TRIDENTS;
             case EXPLOSIVE_SHOT -> PrimarySkillType.ARCHERY;
             case MACES_SUPER_ABILITY -> PrimarySkillType.MACES;
+            case SPEARS_SUPER_ABILITY -> PrimarySkillType.SPEARS;
         };
     }
 
     /**
-     * Makes a list of the "nice" version of sub skill names Used in tab completion mostly
+     * Makes a list of the "nice" version of sub skill names. Used in tab completion mostly.
      *
      * @return a list of formatted sub skill names
      */
@@ -272,9 +343,12 @@ public class SkillTools {
     }
 
     /**
-     * Matches a string of a skill to a skill This is NOT case sensitive First it checks the locale
-     * file and tries to match by the localized name of the skill Then if nothing is found it checks
-     * against the hard coded "name" of the skill, which is just its name in English
+     * Matches a string of a skill to a skill.
+     * This is NOT case-sensitive.
+     * <p>
+     * First it checks the locale file and tries to match by the localized name of the skill.
+     * Then if nothing is found it checks against the hard coded "name" of the skill,
+     * which is just its name in English.
      *
      * @param skillName target skill name
      * @return the matching PrimarySkillType if one is found, otherwise null
@@ -282,8 +356,9 @@ public class SkillTools {
     public PrimarySkillType matchSkill(String skillName) {
         if (!pluginRef.getGeneralConfig().getLocale().equalsIgnoreCase("en_US")) {
             for (PrimarySkillType type : PrimarySkillType.values()) {
-                if (skillName.equalsIgnoreCase(LocaleLoader.getString(
-                        StringUtils.getCapitalized(type.name()) + ".SkillName"))) {
+                String localized = LocaleLoader.getString(
+                        StringUtils.getCapitalized(type.name()) + ".SkillName");
+                if (skillName.equalsIgnoreCase(localized)) {
                     return type;
                 }
             }
@@ -297,15 +372,15 @@ public class SkillTools {
 
         if (!skillName.equalsIgnoreCase("all")) {
             pluginRef.getLogger()
-                    .warning("Invalid mcMMO skill (" + skillName + ")"); //TODO: Localize
+                    .warning("Invalid mcMMO skill (" + skillName + ")"); // TODO: Localize
         }
 
         return null;
     }
 
     /**
-     * Gets the PrimarySkillStype to which a SubSkillType belongs Return null if it does not belong
-     * to one.. which should be impossible in most circumstances
+     * Gets the PrimarySkillType to which a SubSkillType belongs.
+     * Returns null if it does not belong to one (which should be impossible in most circumstances).
      *
      * @param subSkillType target subskill
      * @return the PrimarySkillType of this SubSkill, null if it doesn't exist
@@ -315,8 +390,8 @@ public class SkillTools {
     }
 
     /**
-     * Gets the PrimarySkillStype to which a SuperAbilityType belongs Return null if it does not
-     * belong to one.. which should be impossible in most circumstances
+     * Gets the PrimarySkillType to which a SuperAbilityType belongs.
+     * Returns null if it does not belong to one (which should be impossible in most circumstances).
      *
      * @param superAbilityType target super ability
      * @return the PrimarySkillType of this SuperAbilityType, null if it doesn't exist
@@ -326,16 +401,15 @@ public class SkillTools {
     }
 
     public SuperAbilityType getSuperAbility(PrimarySkillType primarySkillType) {
-        if (mainActivatedAbilityChildMap.get(primarySkillType) == null) {
-            return null;
-        }
-
         return mainActivatedAbilityChildMap.get(primarySkillType);
     }
 
     public boolean isSuperAbilityUnlocked(PrimarySkillType primarySkillType, Player player) {
-        SuperAbilityType superAbilityType = mcMMO.p.getSkillTools()
-                .getSuperAbility(primarySkillType);
+        SuperAbilityType superAbilityType = getSuperAbility(primarySkillType);
+        if (superAbilityType == null) {
+            return false;
+        }
+
         SubSkillType subSkillType = superAbilityType.getSubSkillTypeDefinition();
         return RankUtils.hasUnlockedSubskill(player, subSkillType);
     }
@@ -368,7 +442,6 @@ public class SkillTools {
         return ExperienceConfig.getInstance().getFormulaSkillModifier(primarySkillType);
     }
 
-    // TODO: This is a little "hacky", we probably need to add something to distinguish child skills in the enum, or to use another enum for them
     public static boolean isChildSkill(PrimarySkillType primarySkillType) {
         return switch (primarySkillType) {
             case SALVAGE, SMELTING -> true;
@@ -392,8 +465,10 @@ public class SkillTools {
     }
 
     public boolean canCombatSkillsTrigger(PrimarySkillType primarySkillType, Entity target) {
-        return (target instanceof Player || (target instanceof Tameable
-                && ((Tameable) target).isTamed())) ? getPVPEnabled(primarySkillType)
+        boolean isPlayerOrTamed = (target instanceof Player)
+                || (target instanceof Tameable && ((Tameable) target).isTamed());
+        return isPlayerOrTamed
+                ? getPVPEnabled(primarySkillType)
                 : getPVEEnabled(primarySkillType);
     }
 
@@ -410,7 +485,7 @@ public class SkillTools {
     }
 
     public int getLevelCap(@NotNull PrimarySkillType primarySkillType) {
-        return mcMMO.p.getGeneralConfig().getLevelCap(primarySkillType);
+        return pluginRef.getGeneralConfig().getLevelCap(primarySkillType);
     }
 
     /**
@@ -445,17 +520,12 @@ public class SkillTools {
     }
 
     public @NotNull ImmutableList<PrimarySkillType> getChildSkillParents(
-            PrimarySkillType childSkill)
-            throws IllegalArgumentException {
-        switch (childSkill) {
-            case SALVAGE -> {
-                return SALVAGE_PARENTS;
-            }
-            case SMELTING -> {
-                return SMELTING_PARENTS;
-            }
+            PrimarySkillType childSkill) throws IllegalArgumentException {
+        return switch (childSkill) {
+            case SALVAGE -> SALVAGE_PARENTS;
+            case SMELTING -> SMELTING_PARENTS;
             default -> throw new IllegalArgumentException(
                     "Skill " + childSkill + " is not a child skill");
-        }
+        };
     }
 }

+ 18 - 0
src/main/java/com/gmail/nossr50/util/text/TextComponentFactory.java

@@ -544,7 +544,25 @@ public class TextComponentFactory {
         componentBuilder.append(Component.newline());
     }
 
+    /**
+     * @deprecated use appendSubSkillTextComponents(Player, List<Component>, PrimarySkillType)
+     * @param player target player
+     * @param textComponents list to append to
+     * @param parentSkill the parent skill
+     */
+    @Deprecated(since = "2.2.046", forRemoval = true)
     public static void getSubSkillTextComponents(Player player, List<Component> textComponents,
+        PrimarySkillType parentSkill) {
+        appendSubSkillTextComponents(player, textComponents, parentSkill);
+    }
+
+    /**
+     * Appends sub-skill text components to a list for a given parent skill
+     * @param player target player
+     * @param textComponents list to append to
+     * @param parentSkill the parent skill
+     */
+    public static void appendSubSkillTextComponents(Player player, List<Component> textComponents,
             PrimarySkillType parentSkill) {
         for (SubSkillType subSkillType : SubSkillType.values()) {
             if (subSkillType.getParentSkill() == parentSkill) {

+ 16 - 1
src/main/resources/advanced.yml

@@ -647,4 +647,19 @@ Skills:
                 Rank_1: 10
                 Rank_2: 15
                 Rank_3: 20
-                Rank_4: 33
+                Rank_4: 33
+    Spears:
+        SpearMastery:
+            Rank_Damage_Multiplier: 0.4
+        Momentum:
+            Chance_To_Apply_On_Hit:
+                Rank_1: 5
+                Rank_2: 10
+                Rank_3: 15
+                Rank_4: 20
+                Rank_5: 25
+                Rank_6: 30
+                Rank_7: 35
+                Rank_8: 40
+                Rank_9: 45
+                Rank_10: 50

+ 6 - 0
src/main/resources/config.yml

@@ -229,6 +229,7 @@ Hardcore:
             Tridents: false
             Crossbows: false
             Maces: false
+            Spears: false
     Vampirism:
         Leech_Percentage: 5.0
         Level_Threshold: 0
@@ -249,6 +250,7 @@ Hardcore:
             Tridents: false
             Crossbows: false
             Maces: false
+            Spears: false
 
 #
 #  Settings for SMP Mods
@@ -427,6 +429,10 @@ Skills:
         Enabled_For_PVP: true
         Enabled_For_PVE: true
         Level_Cap: 0
+    Spears:
+        Enabled_For_PVP: true
+        Enabled_For_PVE: true
+        Level_Cap: 0
     Taming:
         Enabled_For_PVP: true
         Enabled_For_PVE: true

+ 11 - 0
src/main/resources/experience.yml

@@ -95,6 +95,10 @@ Experience_Bars:
         Enable: true
         Color: BLUE
         BarStyle: SEGMENTED_6
+    Spears:
+        Enable: true
+        Color: BLUE
+        BarStyle: SEGMENTED_6
     Repair:
         Enable: true
         Color: PURPLE
@@ -167,6 +171,7 @@ Experience_Formula:
 
     # Experience gained will get multiplied by these values. 1.0 by default, 0.5 means half XP gained. This happens right before multiplying the XP by the global multiplier.
     Skill_Multiplier:
+        Spears: 1.0
         Maces: 1.0
         Crossbows: 1.0
         Tridents: 1.0
@@ -218,6 +223,7 @@ Diminished_Returns:
         Crossbows: 20000
         Tridents: 20000
         Maces: 20000
+        Spears: 20000
 
     Time_Interval: 10
 
@@ -582,6 +588,7 @@ Experience_Values:
     Taming:
         Animal_Taming:
             Camel: 1300
+            Camel_Husk: 1300
             Sniffer: 1500
             Snifflet: 900
             Llama: 1200
@@ -600,12 +607,15 @@ Experience_Values:
             Goat: 250
             Axolotl: 600
             Frog: 900
+            Nautilus: 1700
+            Zombie_Nautilus: 1700
     Combat:
         Multiplier:
             Animals: 1.0
             Armadillo: 1.1
             Creeper: 4.0
             Skeleton: 3.0
+            Parched: 2.5
             Spider: 2.0
             Giant: 4.0
             Zombie: 2.0
@@ -683,6 +693,7 @@ Experience_Values:
             Sniffer: 1.1
             Snifflet: 1.1
             Camel: 1.2
+            Camel_Husk: 1.25
             Bogged: 2.0
             Breeze: 4.0
             Armor_Stand: 0.0

+ 26 - 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,24 @@ 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.SubSkill.Momentum.Name=Momentum
+Spears.SubSkill.Momentum.Description=Adds a chance to increase movement speed for a short duration when attacking.
+Spears.SubSkill.Momentum.Stat=Momentum Chance
+Spears.SubSkill.Momentum.Stat.Extra=[[DARK_AQUA]]Momentum Duration: &e{0}s
+Spears.SubSkill.Momentum.Activated=MOMENTUM ACTIVATED!
+Spears.SubSkill.SpearMastery.Name=Spear Mastery
+Spears.SubSkill.SpearMastery.Description=Adds bonus damage to your attacks.
+Spears.SubSkill.SpearMastery.Stat=Spear Mastery Bonus DMG
+Spears.Listener=Spears:
+
 #SWORDS
 Swords.Ability.Lower=&7You lower your sword.
 Swords.Ability.Ready=&3You &6ready&3 your Sword.
@@ -913,6 +934,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 +1069,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}

+ 185 - 112
src/main/resources/plugin.yml

@@ -1,20 +1,20 @@
 name: mcMMO
 version: ${project.version}
 description: >
-  The goal of mcMMO is to take core Minecraft game mechanics and expand them into
-  add an extensive and quality RPG experience. Everything in mcMMO has been carefully
-  thought out and is constantly being improved upon. Currently, mcMMO adds thirteen
-  unique skills to train and level in. Each of these skills is highly customizable
-  through our configuration files, allowing server admins to tweak mcMMO to best suit
-  the needs of his or her server. Know that the mcMMO team is dedicated to providing
-  an ever-evolving experience, and that we carefully read all feedback and bug reports
-  in order to evaluate and balance the mechanics of mcMMO in every update.
+    The goal of mcMMO is to take core Minecraft game mechanics and expand them into
+    add an extensive and quality RPG experience. Everything in mcMMO has been carefully
+    thought out and is constantly being improved upon. Currently, mcMMO adds thirteen
+    unique skills to train and level in. Each of these skills is highly customizable
+    through our configuration files, allowing server admins to tweak mcMMO to best suit
+    the needs of his or her server. Know that the mcMMO team is dedicated to providing
+    an ever-evolving experience, and that we carefully read all feedback and bug reports
+    in order to evaluate and balance the mechanics of mcMMO in every update.
 
 author: nossr50
-authors: [GJ, NuclearW, bm01, Glitchfinder, TfT_02, t00thpick1, Riking, electronicboy, kashike]
+authors: [ GJ, NuclearW, bm01, Glitchfinder, TfT_02, t00thpick1, Riking, electronicboy, kashike ]
 website: https://www.mcmmo.org
 main: com.gmail.nossr50.mcMMO
-softdepend: [WorldGuard, CombatTag, HealthBar, PlaceholderAPI, ProtocolLib]
+softdepend: [ WorldGuard, CombatTag, HealthBar, PlaceholderAPI, ProtocolLib ]
 load: POSTWORLD
 folia-supported: true
 api-version: 1.13
@@ -26,14 +26,14 @@ commands:
     mmocompat:
         description: Information about the server and whether its considered fully compatible or running in compatibility mode
     mmodebug:
-        aliases: [mcmmodebugmode]
+        aliases: [ mcmmodebugmode ]
         description: Toggles a debug mode which will print useful information to chat
     mmoinfo:
-        aliases: [mcinfo]
+        aliases: [ mcinfo ]
         description: Info pages for mcMMO
         permission: mcmmo.commands.mmoinfo
     xprate:
-        aliases: [mcxprate]
+        aliases: [ mcxprate ]
         description: Modify the xp rate or start an event
         permission: mcmmo.commands.xprate
     mcmmo:
@@ -59,7 +59,7 @@ commands:
         permission: mcmmo.commands.mcrefresh
     mccooldown:
         description: Show the cooldowns on all your mcMMO abilities
-        aliases: [mccooldowns]
+        aliases: [ mccooldowns ]
         permission: mcmmo.commands.mccooldown
     mcchatspy:
         description: Toggle mcMMO Party Chat spying on/off
@@ -68,7 +68,7 @@ commands:
         description: Toggle mcMMO god-mode on/off
         permission: mcmmo.commands.mcgod
     mcstats:
-        aliases: [stats]
+        aliases: [ stats ]
         description: Shows your mcMMO stats and xp
         permission: mcmmo.commands.mcstats
     mcremove:
@@ -84,7 +84,7 @@ commands:
         description: Create/join a party
         permission: mcmmo.commands.party
     inspect:
-        aliases: [mcinspect, mmoinspect]
+        aliases: [ mcinspect, mmoinspect ]
         description: View detailed mcMMO info on another player
         permission: mcmmo.commands.inspect
     mmoshowdb:
@@ -94,7 +94,7 @@ commands:
         description: Convert between different database and formula types
         permission: mcmmo.commands.mcconvert
     partychat:
-        aliases: [pc, p]
+        aliases: [ pc, p ]
         description: Toggle Party chat or send party chat messages
         permission: mcmmo.chat.partychat
     skillreset:
@@ -145,6 +145,9 @@ commands:
     smelting:
         description: Detailed mcMMO skill info
         permission: mcmmo.commands.smelting
+    spears:
+        description: Detailed mcMMO skill info
+        permission: mcmmo.commands.spears
     alchemy:
         description: Detailed mcMMO skill info
         permission: mcmmo.commands.alchemy
@@ -157,24 +160,24 @@ commands:
     mmopower:
         description: Shows skill mastery and power level info
         permission: mcmmo.commands.mmopower
-        aliases: [mmopowerlevel, powerlevel]
+        aliases: [ mmopowerlevel, powerlevel ]
     adminchat:
-        aliases: [ac, a]
+        aliases: [ ac, a ]
         description: Toggle Admin chat or send admin chat messages
         permission: mcmmo.chat.adminchat
     mcpurge:
         description: Purge users with 0 powerlevel and/or who haven't connected in several months from the server DB.
         permission: mcmmo.commands.mcpurge
     mcnotify:
-        aliases: [notify]
+        aliases: [ notify ]
         description: Toggle mcMMO abilities chat display notifications on/off
         permission: mcmmo.commands.mcnotify
     mcscoreboard:
-        aliases: [mcsb]
+        aliases: [ mcsb ]
         description: Manage your mcMMO Scoreboard
         permission: mcmmo.commands.mcscoreboard
     mcmmoreloadlocale:
-        aliases: [mcreloadlocale]
+        aliases: [ mcreloadlocale ]
         description: Reloads locale
         permission: mcmmo.commands.reloadlocale
 permissions:
@@ -237,6 +240,7 @@ permissions:
             mcmmo.ability.repair.all: true
             mcmmo.ability.salvage.all: true
             mcmmo.ability.smelting.all: true
+            mcmmo.ability.spears.all: true
             mcmmo.ability.swords.all: true
             mcmmo.ability.taming.all: true
             mcmmo.ability.tridents.all: true
@@ -320,19 +324,19 @@ permissions:
     mcmmo.ability.axes.skullsplitter:
         description: Allows access to the Skull Splitter ability
     mcmmo.ability.crossbows.*:
-          description: Allows access to all Crossbows abilities
-          children:
+        description: Allows access to all Crossbows abilities
+        children:
             mcmmo.ability.crossbows.all: true
     mcmmo.ability.crossbows.all:
-            description: Allows access to all Crossbows abilities
-            children:
-                mcmmo.ability.crossbows.trickshot: true
-                mcmmo.ability.crossbows.poweredshot: true
-                mcmmo.ability.crossbows.crossbowslimitbreak: true
+        description: Allows access to all Crossbows abilities
+        children:
+            mcmmo.ability.crossbows.trickshot: true
+            mcmmo.ability.crossbows.poweredshot: true
+            mcmmo.ability.crossbows.crossbowslimitbreak: true
     mcmmo.ability.crossbows.crossbowslimitbreak:
-            description: Adds damage to crossbows
+        description: Adds damage to crossbows
     mcmmo.ability.crossbows.trickshot:
-            description: Allows access to the Trick Shot ability
+        description: Allows access to the Trick Shot ability
     mcmmo.ability.crossbows.poweredshot:
         description: Allows access to the Powered Shot ability
     mcmmo.ability.excavation.*:
@@ -646,6 +650,23 @@ permissions:
         description: Allows access to the Second Smelt ability
     mcmmo.ability.smelting.vanillaxpboost:
         description: Allows vanilla XP boost from Smelting
+    mcmmo.ability.spears.*:
+        default: false
+        description: Allows access to all Spear abilities
+        children:
+            mcmmo.ability.spears.all: true
+    mcmmo.ability.spears.all:
+        description: Allows access to all Spear abilities
+        children:
+            mcmmo.ability.spears.spearslimitbreak: true
+            mcmmo.ability.spears.momentum: true
+            mcmmo.ability.spears.spearmastery: true
+    mcmmo.ability.spears.spearslimitbreak:
+        description: Adds damage to spears
+    mcmmo.ability.spears.momentum:
+        description: Allows access to the Spear Momentum ability
+    mcmmo.ability.spears.spearmastery:
+        description: Allows access to the Spear Mastery ability
     mcmmo.ability.swords.*:
         default: false
         description: Allows access to all Swords abilities
@@ -885,6 +906,7 @@ permissions:
             mcmmo.commands.repair: true
             mcmmo.commands.salvage: true
             mcmmo.commands.smelting: true
+            mcmmo.commands.spears: true
             mcmmo.commands.swords: true
             mcmmo.commands.taming: true
             mcmmo.commands.unarmed: true
@@ -898,7 +920,7 @@ permissions:
             mcmmo.commands.addxp: true
             mcmmo.commands.addxp.others: true
             mcmmo.commands.defaults: true
-#            mcmmo.commands.hardcore.all: true
+            #            mcmmo.commands.hardcore.all: true
             mcmmo.commands.inspect.far: true
             mcmmo.commands.inspect.hidden: true
             mcmmo.commands.mcability.others: true
@@ -918,7 +940,7 @@ permissions:
             mcmmo.commands.ptp.world.all: true
             mcmmo.commands.reloadlocale: true
             mcmmo.commands.skillreset.all: true
-#            mcmmo.commands.vampirism.all: true
+            #            mcmmo.commands.vampirism.all: true
             mcmmo.commands.xprate.all: true
     mcmmo.commands.acrobatics:
         description: Allows access to the acrobatics command
@@ -1058,6 +1080,7 @@ permissions:
             mcmmo.commands.mctop.repair: true
             mcmmo.commands.mctop.salvage: true
             mcmmo.commands.mctop.smelting: true
+            mcmmo.commands.mctop.spears: true
             mcmmo.commands.mctop.swords: true
             mcmmo.commands.mctop.taming: true
             mcmmo.commands.mctop.tridents: true
@@ -1091,6 +1114,8 @@ permissions:
         description: Allows access to the mctop command for salvage
     mcmmo.commands.mctop.smelting:
         description: Allows access to the mctop command for smelting
+    mcmmo.commands.mctop.spears:
+        description: Allows access to the mctop command for spears
     mcmmo.commands.mctop.swords:
         description: Allows access to the mctop command for swords
     mcmmo.commands.mctop.taming:
@@ -1239,6 +1264,7 @@ permissions:
             mcmmo.commands.skillreset.repair: true
             mcmmo.commands.skillreset.salvage: true
             mcmmo.commands.skillreset.smelting: true
+            mcmmo.commands.skillreset.spears: true
             mcmmo.commands.skillreset.swords: true
             mcmmo.commands.skillreset.taming: true
             mcmmo.commands.skillreset.unarmed: true
@@ -1268,6 +1294,8 @@ permissions:
         description: Allows access to the skillreset command for crossbows
     mcmmo.commands.skillreset.tridents:
         description: Allows access to the skillreset command for tridents
+    mcmmo.commands.skillreset.spears:
+        description: Allows access to the skillreset command for spears
     mcmmo.commands.skillreset.maces:
         description: Allows access to the skillreset command for maces
     mcmmo.commands.skillreset.others.*:
@@ -1290,6 +1318,7 @@ permissions:
             mcmmo.commands.skillreset.others.repair: true
             mcmmo.commands.skillreset.others.salvage: true
             mcmmo.commands.skillreset.others.smelting: true
+            mcmmo.commands.skillreset.others.spears: true
             mcmmo.commands.skillreset.others.swords: true
             mcmmo.commands.skillreset.others.taming: true
             mcmmo.commands.skillreset.others.unarmed: true
@@ -1321,6 +1350,8 @@ permissions:
         description: Allows access to the skillreset command for salvage for other players
     mcmmo.commands.skillreset.others.smelting:
         description: Allows access to the skillreset command for smelting for other players
+    mcmmo.commands.skillreset.others.spears:
+        description: Allows access to the skillreset command for spears for other players
     mcmmo.commands.skillreset.others.swords:
         description: Allows access to the skillreset command for swords for other players
     mcmmo.commands.skillreset.others.taming:
@@ -1406,7 +1437,7 @@ permissions:
         default: false
         description: implies access to all mcmmo perks
         children:
-            mcmmo.perks.all: true 
+            mcmmo.perks.all: true
     mcmmo.perks.all:
         default: false
         description: implies access to all mcmmo perks
@@ -1497,6 +1528,7 @@ permissions:
             mcmmo.perks.lucky.repair: true
             mcmmo.perks.lucky.salvage: true
             mcmmo.perks.lucky.smelting: true
+            mcmmo.perks.lucky.spears: true
             mcmmo.perks.lucky.swords: true
             mcmmo.perks.lucky.taming: true
             mcmmo.perks.lucky.unarmed: true
@@ -1539,6 +1571,9 @@ permissions:
     mcmmo.perks.lucky.salvage:
         default: false
         description: Gives Salvage abilities & skills a 33.3% better chance to activate.
+    mcmmo.perks.lucky.spears:
+        default: false
+        description: Gives Spears abilities & skills a 33.3% better chance to activate.
     mcmmo.perks.lucky.smelting:
         default: false
         description: Gives Smelting abilities & skills a 33.3% better chance to activate.
@@ -1600,6 +1635,7 @@ permissions:
             mcmmo.perks.xp.150percentboost.mining: true
             mcmmo.perks.xp.150percentboost.repair: true
             mcmmo.perks.xp.150percentboost.smelting: true
+            mcmmo.perks.xp.150percentboost.spears: true
             mcmmo.perks.xp.150percentboost.swords: true
             mcmmo.perks.xp.150percentboost.taming: true
             mcmmo.perks.xp.150percentboost.tridents: true
@@ -1641,6 +1677,9 @@ permissions:
     mcmmo.perks.xp.150percentboost.smelting:
         default: false
         description: Multiplies incoming Smelting XP by 2.5
+    mcmmo.perks.xp.150percentboost.spears:
+        default: false
+        description: Multiplies incoming Spears XP by 2.5
     mcmmo.perks.xp.150percentboost.swords:
         default: false
         description: Multiplies incoming Swords XP by 2.5
@@ -1682,6 +1721,7 @@ permissions:
             mcmmo.perks.xp.50percentboost.mining: true
             mcmmo.perks.xp.50percentboost.repair: true
             mcmmo.perks.xp.50percentboost.smelting: true
+            mcmmo.perks.xp.50percentboost.spears: true
             mcmmo.perks.xp.50percentboost.swords: true
             mcmmo.perks.xp.50percentboost.taming: true
             mcmmo.perks.xp.50percentboost.tridents: true
@@ -1720,6 +1760,9 @@ permissions:
     mcmmo.perks.xp.50percentboost.repair:
         default: false
         description: Multiplies incoming Repair XP by 1.5
+    mcmmo.perks.xp.50percentboost.spears:
+        default: false
+        description: Multiplies incoming Spears XP by 1.5
     mcmmo.perks.xp.50percentboost.smelting:
         default: false
         description: Multiplies incoming Smelting XP by 1.5
@@ -1739,87 +1782,91 @@ permissions:
         default: false
         description: Multiplies incoming Woodcutting XP by 1.5
     mcmmo.perks.xp.25percentboost.*:
-      default: false
-      description: Multiplies incoming XP by 1.25
-      children:
-        mcmmo.perks.xp.25percentboost.all: true
-      mcmmo.perks.xp.25percentboost:
-        default: false
-        description: Multiplies incoming XP by 1.25
-        children:
-          mcmmo.perks.xp.25percentboost.all: true
-      mcmmo.perks.xp.25percentboost.all:
         default: false
         description: Multiplies incoming XP by 1.25
         children:
-            mcmmo.perks.xp.25percentboost.acrobatics: true
-            mcmmo.perks.xp.25percentboost.alchemy: true
-            mcmmo.perks.xp.25percentboost.archery: true
-            mcmmo.perks.xp.25percentboost.axes: true
-            mcmmo.perks.xp.25percentboost.crossbows: true
-            mcmmo.perks.xp.25percentboost.excavation: true
-            mcmmo.perks.xp.25percentboost.fishing: true
-            mcmmo.perks.xp.25percentboost.herbalism: true
-            mcmmo.perks.xp.25percentboost.maces: true
-            mcmmo.perks.xp.25percentboost.mining: true
-            mcmmo.perks.xp.25percentboost.repair: true
-            mcmmo.perks.xp.25percentboost.smelting: true
-            mcmmo.perks.xp.25percentboost.swords: true
-            mcmmo.perks.xp.25percentboost.taming: true
-            mcmmo.perks.xp.25percentboost.tridents: true
-            mcmmo.perks.xp.25percentboost.unarmed: true
-            mcmmo.perks.xp.25percentboost.woodcutting: true
-      mcmmo.perks.xp.25percentboost.acrobatics:
-        default: false
-        description: Multiplies incoming Acrobatics XP by 1.25
-      mcmmo.perks.xp.25percentboost.alchemy:
-        default: false
-        description: Multiplies incoming Acrobatics XP by 1.25
-      mcmmo.perks.xp.25percentboost.archery:
-        default: false
-        description: Multiplies incoming Archery XP by 1.25
-      mcmmo.perks.xp.25percentboost.axes:
-        default: false
-        description: Multiplies incoming Axes XP by 1.25
-      mcmmo.perks.xp.25percentboost.crossbows:
-        default: false
-        description: Multiplies incoming Crossbows XP by 1.25
-      mcmmo.perks.xp.25percentboost.excavation:
-        default: false
-        description: Multiplies incoming Excavation XP by 1.25
-      mcmmo.perks.xp.25percentboost.fishing:
-        default: false
-        description: Multiplies incoming Fishing XP by 1.25
-      mcmmo.perks.xp.25percentboost.herbalism:
-        default: false
-        description: Multiplies incoming Herbalism XP by 1.25
-      mcmmo.perks.xp.25percentboost.maces:
-        default: false
-        description: Multiplies incoming Maces XP by 1.25
-      mcmmo.perks.xp.25percentboost.mining:
-        default: false
-        description: Multiplies incoming Mining XP by 1.25
-      mcmmo.perks.xp.25percentboost.repair:
-        default: false
-        description: Multiplies incoming Repair XP by 1.25
-      mcmmo.perks.xp.25percentboost.smelting:
-        default: false
-        description: Multiplies incoming Smelting XP by 1.25
-      mcmmo.perks.xp.25percentboost.swords:
-        default: false
-        description: Multiplies incoming Swords XP by 1.25
-      mcmmo.perks.xp.25percentboost.taming:
-        default: false
-        description: Multiplies incoming Taming XP by 1.25
-      mcmmo.perks.xp.25percentboost.tridents:
-        default: false
-        description: Multiplies incoming Tridents XP by 1.25
-      mcmmo.perks.xp.25percentboost.unarmed:
-        default: false
-        description: Multiplies incoming Unarmed XP by 1.5
-      mcmmo.perks.xp.25percentboost.woodcutting:
-        default: false
-        description: Multiplies incoming Woodcutting XP by 1.25
+            mcmmo.perks.xp.25percentboost.all: true
+        mcmmo.perks.xp.25percentboost:
+            default: false
+            description: Multiplies incoming XP by 1.25
+            children:
+                mcmmo.perks.xp.25percentboost.all: true
+        mcmmo.perks.xp.25percentboost.all:
+            default: false
+            description: Multiplies incoming XP by 1.25
+            children:
+                mcmmo.perks.xp.25percentboost.acrobatics: true
+                mcmmo.perks.xp.25percentboost.alchemy: true
+                mcmmo.perks.xp.25percentboost.archery: true
+                mcmmo.perks.xp.25percentboost.axes: true
+                mcmmo.perks.xp.25percentboost.crossbows: true
+                mcmmo.perks.xp.25percentboost.excavation: true
+                mcmmo.perks.xp.25percentboost.fishing: true
+                mcmmo.perks.xp.25percentboost.herbalism: true
+                mcmmo.perks.xp.25percentboost.maces: true
+                mcmmo.perks.xp.25percentboost.mining: true
+                mcmmo.perks.xp.25percentboost.repair: true
+                mcmmo.perks.xp.25percentboost.smelting: true
+                mcmmo.perks.xp.25percentboost.spears: true
+                mcmmo.perks.xp.25percentboost.swords: true
+                mcmmo.perks.xp.25percentboost.taming: true
+                mcmmo.perks.xp.25percentboost.tridents: true
+                mcmmo.perks.xp.25percentboost.unarmed: true
+                mcmmo.perks.xp.25percentboost.woodcutting: true
+        mcmmo.perks.xp.25percentboost.acrobatics:
+            default: false
+            description: Multiplies incoming Acrobatics XP by 1.25
+        mcmmo.perks.xp.25percentboost.alchemy:
+            default: false
+            description: Multiplies incoming Acrobatics XP by 1.25
+        mcmmo.perks.xp.25percentboost.archery:
+            default: false
+            description: Multiplies incoming Archery XP by 1.25
+        mcmmo.perks.xp.25percentboost.axes:
+            default: false
+            description: Multiplies incoming Axes XP by 1.25
+        mcmmo.perks.xp.25percentboost.crossbows:
+            default: false
+            description: Multiplies incoming Crossbows XP by 1.25
+        mcmmo.perks.xp.25percentboost.excavation:
+            default: false
+            description: Multiplies incoming Excavation XP by 1.25
+        mcmmo.perks.xp.25percentboost.fishing:
+            default: false
+            description: Multiplies incoming Fishing XP by 1.25
+        mcmmo.perks.xp.25percentboost.herbalism:
+            default: false
+            description: Multiplies incoming Herbalism XP by 1.25
+        mcmmo.perks.xp.25percentboost.maces:
+            default: false
+            description: Multiplies incoming Maces XP by 1.25
+        mcmmo.perks.xp.25percentboost.mining:
+            default: false
+            description: Multiplies incoming Mining XP by 1.25
+        mcmmo.perks.xp.25percentboost.repair:
+            default: false
+            description: Multiplies incoming Repair XP by 1.25
+        mcmmo.perks.xp.25percentboost.smelting:
+            default: false
+            description: Multiplies incoming Smelting XP by 1.25
+        mcmmo.perks.xp.25percentboost.spears:
+            default: false
+            description: Multiplies incoming Spears XP by 1.25
+        mcmmo.perks.xp.25percentboost.swords:
+            default: false
+            description: Multiplies incoming Swords XP by 1.25
+        mcmmo.perks.xp.25percentboost.taming:
+            default: false
+            description: Multiplies incoming Taming XP by 1.25
+        mcmmo.perks.xp.25percentboost.tridents:
+            default: false
+            description: Multiplies incoming Tridents XP by 1.25
+        mcmmo.perks.xp.25percentboost.unarmed:
+            default: false
+            description: Multiplies incoming Unarmed XP by 1.5
+        mcmmo.perks.xp.25percentboost.woodcutting:
+            default: false
+            description: Multiplies incoming Woodcutting XP by 1.25
     mcmmo.perks.xp.10percentboost.*:
         default: false
         description: Multiplies incoming XP by 1.1
@@ -1846,6 +1893,7 @@ permissions:
             mcmmo.perks.xp.10percentboost.mining: true
             mcmmo.perks.xp.10percentboost.repair: true
             mcmmo.perks.xp.10percentboost.smelting: true
+            mcmmo.perks.xp.10percentboost.spears: true
             mcmmo.perks.xp.10percentboost.swords: true
             mcmmo.perks.xp.10percentboost.taming: true
             mcmmo.perks.xp.10percentboost.tridents: true
@@ -1884,6 +1932,9 @@ permissions:
     mcmmo.perks.xp.10percentboost.repair:
         default: false
         description: Multiplies incoming Repair XP by 1.1
+    mcmmo.perks.xp.10percentboost.spears:
+        default: false
+        description: Multiplies incoming Spears XP by 1.1
     mcmmo.perks.xp.10percentboost.smelting:
         default: false
         description: Multiplies incoming Smelting XP by 1.1
@@ -1928,6 +1979,7 @@ permissions:
             mcmmo.perks.xp.customboost.mining: true
             mcmmo.perks.xp.customboost.repair: true
             mcmmo.perks.xp.customboost.smelting: true
+            mcmmo.perks.xp.customboost.spears: true
             mcmmo.perks.xp.customboost.swords: true
             mcmmo.perks.xp.customboost.taming: true
             mcmmo.perks.xp.customboost.tridents: true
@@ -1966,6 +2018,9 @@ permissions:
     mcmmo.perks.xp.customboost.repair:
         default: false
         description: Multiplies incoming Repair XP by the boost amount defined in the experience config
+    mcmmo.perks.xp.customboost.spears:
+        default: false
+        description: Multiplies incoming Smelting XP by the boost amount defined in the experience config
     mcmmo.perks.xp.customboost.smelting:
         default: false
         description: Multiplies incoming Smelting XP by the boost amount defined in the experience config
@@ -2010,6 +2065,7 @@ permissions:
             mcmmo.perks.xp.double.mining: true
             mcmmo.perks.xp.double.repair: true
             mcmmo.perks.xp.double.smelting: true
+            mcmmo.perks.xp.double.spears: true
             mcmmo.perks.xp.double.swords: true
             mcmmo.perks.xp.double.taming: true
             mcmmo.perks.xp.double.tridents: true
@@ -2048,6 +2104,9 @@ permissions:
     mcmmo.perks.xp.double.repair:
         default: false
         description: Doubles incoming Repair XP
+    mcmmo.perks.xp.double.spears:
+        default: false
+        description: Doubles incoming Smelting XP
     mcmmo.perks.xp.double.smelting:
         default: false
         description: Doubles incoming Smelting XP
@@ -2092,6 +2151,7 @@ permissions:
             mcmmo.perks.xp.quadruple.mining: true
             mcmmo.perks.xp.quadruple.repair: true
             mcmmo.perks.xp.quadruple.smelting: true
+            mcmmo.perks.xp.quadruple.spears: true
             mcmmo.perks.xp.quadruple.swords: true
             mcmmo.perks.xp.quadruple.taming: true
             mcmmo.perks.xp.quadruple.tridents: true
@@ -2133,6 +2193,9 @@ permissions:
     mcmmo.perks.xp.quadruple.smelting:
         default: false
         description: Quadruples incoming Smelting XP
+    mcmmo.perks.xp.quadruple.spears:
+        default: false
+        description: Quadruples incoming Spears XP
     mcmmo.perks.xp.quadruple.swords:
         default: false
         description: Quadruples incoming Swords XP
@@ -2174,6 +2237,7 @@ permissions:
             mcmmo.perks.xp.triple.maces: true
             mcmmo.perks.xp.triple.repair: true
             mcmmo.perks.xp.triple.smelting: true
+            mcmmo.perks.xp.triple.spears: true
             mcmmo.perks.xp.triple.swords: true
             mcmmo.perks.xp.triple.taming: true
             mcmmo.perks.xp.triple.tridents: true
@@ -2215,6 +2279,9 @@ permissions:
     mcmmo.perks.xp.triple.smelting:
         default: false
         description: Triples incoming Smelting XP
+    mcmmo.perks.xp.triple.spears:
+        default: false
+        description: Triples incoming Spears XP
     mcmmo.perks.xp.triple.swords:
         default: false
         description: Triples incoming Swords XP
@@ -2257,6 +2324,7 @@ permissions:
             mcmmo.skills.salvage: true
             mcmmo.skills.swords: true
             mcmmo.skills.smelting: true
+            mcmmo.skills.spears: true
             mcmmo.skills.taming: true
             mcmmo.skills.unarmed: true
             mcmmo.skills.woodcutting: true
@@ -2322,6 +2390,11 @@ permissions:
         children:
             mcmmo.ability.smelting.all: true
             mcmmo.commands.smelting: true
+    mcmmo.skills.spears:
+        description: Allows access to the Spears skill
+        children:
+            mcmmo.ability.spears.all: true
+            mcmmo.commands.spears: true
     mcmmo.skills.swords:
         description: Allows access to the Swords skill
         children:

+ 24 - 0
src/main/resources/repair.vanilla.yml

@@ -55,6 +55,9 @@ Repairables:
     WOODEN_SWORD:
         MinimumLevel: 0
         XpMultiplier: .25
+    WOODEN_SPEAR:
+        MinimumLevel: 0
+        XpMultiplier: .25
     WOODEN_SHOVEL:
         MinimumLevel: 0
         XpMultiplier: .16
@@ -74,6 +77,9 @@ Repairables:
     STONE_SWORD:
         MinimumLevel: 0
         XpMultiplier: .25
+    STONE_SPEAR:
+        MinimumLevel: 0
+        XpMultiplier: .25
     STONE_SHOVEL:
         MinimumLevel: 0
         XpMultiplier: .16
@@ -96,6 +102,12 @@ Repairables:
         ItemType: TOOL
         ItemMaterialCategory: COPPER
         RepairMaterial: COPPER_INGOT
+    COPPER_SPEAR:
+        MinimumLevel: 0
+        XpMultiplier: .3
+        ItemType: TOOL
+        ItemMaterialCategory: COPPER
+        RepairMaterial: COPPER_INGOT
     COPPER_SHOVEL:
         MinimumLevel: 0
         XpMultiplier: .2
@@ -152,6 +164,9 @@ Repairables:
     IRON_SWORD:
         MinimumLevel: 0
         XpMultiplier: .5
+    IRON_SPEAR:
+        MinimumLevel: 0
+        XpMultiplier: .5
     IRON_SHOVEL:
         MinimumLevel: 0
         XpMultiplier: .3
@@ -190,6 +205,9 @@ Repairables:
     GOLDEN_SWORD:
         MinimumLevel: 0
         XpMultiplier: 4
+    GOLDEN_SPEAR:
+        MinimumLevel: 0
+        XpMultiplier: 4
     GOLDEN_SHOVEL:
         MinimumLevel: 0
         XpMultiplier: 2.6
@@ -222,6 +240,9 @@ Repairables:
     DIAMOND_SWORD:
         MinimumLevel: 0
         XpMultiplier: .5
+    DIAMOND_SPEAR:
+        MinimumLevel: 0
+        XpMultiplier: .5
     DIAMOND_SHOVEL:
         MinimumLevel: 0
         XpMultiplier: .3
@@ -255,6 +276,9 @@ Repairables:
     NETHERITE_SWORD:
         MinimumLevel: 0
         XpMultiplier: .6
+    NETHERITE_SPEAR:
+        MinimumLevel: 0
+        XpMultiplier: .6
     NETHERITE_SHOVEL:
         MinimumLevel: 0
         XpMultiplier: .4

+ 28 - 0
src/main/resources/salvage.vanilla.yml

@@ -50,6 +50,10 @@ Salvageables:
         MinimumLevel: 0
         XpMultiplier: .25
         MaximumQuantity: 2
+    WOODEN_SPEAR:
+        MinimumLevel: 0
+        XpMultiplier: .16
+        MaximumQuantity: 1
     WOODEN_SHOVEL:
         MinimumLevel: 0
         XpMultiplier: .16
@@ -74,6 +78,10 @@ Salvageables:
         MinimumLevel: 0
         XpMultiplier: .25
         MaximumQuantity: 2
+    STONE_SPEAR:
+        MinimumLevel: 0
+        XpMultiplier: .16
+        MaximumQuantity: 1
     STONE_SHOVEL:
         MinimumLevel: 0
         XpMultiplier: .16
@@ -98,6 +106,10 @@ Salvageables:
         MinimumLevel: 0
         XpMultiplier: .4
         MaximumQuantity: 2
+    COPPER_SPEAR:
+        MinimumLevel: 0
+        XpMultiplier: .25
+        MaximumQuantity: 1
     COPPER_SHOVEL:
         MinimumLevel: 0
         XpMultiplier: .25
@@ -139,6 +151,10 @@ Salvageables:
         MinimumLevel: 0
         XpMultiplier: .5
         MaximumQuantity: 2
+    IRON_SPEAR:
+        MinimumLevel: 0
+        XpMultiplier: .3
+        MaximumQuantity: 1
     IRON_SHOVEL:
         MinimumLevel: 0
         XpMultiplier: .3
@@ -186,6 +202,10 @@ Salvageables:
         MinimumLevel: 0
         XpMultiplier: 4
         MaximumQuantity: 2
+    GOLDEN_SPEAR:
+        MinimumLevel: 0
+        XpMultiplier: 4
+        MaximumQuantity: 1
     GOLDEN_SHOVEL:
         MinimumLevel: 0
         XpMultiplier: 2.6
@@ -227,6 +247,10 @@ Salvageables:
         MinimumLevel: 50
         XpMultiplier: .5
         MaximumQuantity: 2
+    DIAMOND_SPEAR:
+        MinimumLevel: 50
+        XpMultiplier: .5
+        MaximumQuantity: 1
     DIAMOND_SHOVEL:
         MinimumLevel: 50
         XpMultiplier: .3
@@ -268,6 +292,10 @@ Salvageables:
         MinimumLevel: 100
         XpMultiplier: .5
         MaximumQuantity: 4
+    NETHERITE_SPEAR:
+        MinimumLevel: 100
+        XpMultiplier: .3
+        MaximumQuantity: 4
     NETHERITE_SHOVEL:
         MinimumLevel: 100
         XpMultiplier: .3

+ 66 - 0
src/main/resources/skillranks.yml

@@ -404,6 +404,72 @@ Smelting:
             Rank_6: 750
             Rank_7: 850
             Rank_8: 1000
+Spears:
+    SpearsLimitBreak:
+        Standard:
+            Rank_1: 10
+            Rank_2: 20
+            Rank_3: 30
+            Rank_4: 40
+            Rank_5: 50
+            Rank_6: 60
+            Rank_7: 70
+            Rank_8: 80
+            Rank_9: 90
+            Rank_10: 100
+        RetroMode:
+            Rank_1: 100
+            Rank_2: 200
+            Rank_3: 300
+            Rank_4: 400
+            Rank_5: 500
+            Rank_6: 600
+            Rank_7: 700
+            Rank_8: 800
+            Rank_9: 900
+            Rank_10: 1000
+    SpearMastery:
+        Standard:
+            Rank_1: 5
+            Rank_2: 15
+            Rank_3: 30
+            Rank_4: 45
+            Rank_5: 60
+            Rank_6: 75
+            Rank_7: 90
+            Rank_8: 100
+        RetroMode:
+            Rank_1: 50
+            Rank_2: 150
+            Rank_3: 300
+            Rank_4: 450
+            Rank_5: 600
+            Rank_6: 750
+            Rank_7: 900
+            Rank_8: 1000
+    Momentum:
+        Standard:
+            Rank_1: 1
+            Rank_2: 10
+            Rank_3: 15
+            Rank_4: 20
+            Rank_5: 25
+            Rank_6: 40
+            Rank_7: 50
+            Rank_8: 60
+            Rank_9: 80
+            Rank_10: 95
+        RetroMode:
+            Rank_1: 1
+            Rank_2: 100
+            Rank_3: 150
+            Rank_4: 200
+            Rank_5: 250
+            Rank_6: 400
+            Rank_7: 500
+            Rank_8: 600
+            Rank_9: 800
+            Rank_10: 950
 Salvage:
     ScrapCollector:
         Standard:

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 640 - 268
src/test/java/com/gmail/nossr50/database/FlatFileDatabaseManagerTest.java


+ 1661 - 245
src/test/java/com/gmail/nossr50/database/SQLDatabaseManagerTest.java

@@ -1,245 +1,1661 @@
-//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.database.UpgradeType;
+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.ArgumentCaptor;
+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.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+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
+    // ------------------------------------------------------------------------
+
+    @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();
+        }
+    }
+
+    @ParameterizedTest(name = "{0} - SQL_CHARSET_UTF8MB4 upgrade runs without error")
+    @MethodSource("dbFlavors")
+    void whenCharsetUpgradeIsRequiredShouldUpdateCharacterSet(DbFlavor flavor) {
+        // GIVEN
+        truncateAllCoreTables(flavor);
+
+        // First reset and restub the upgrade manager so only the charset upgrade runs
+        reset(upgradeManager);
+        when(mcMMO.getUpgradeManager()).thenReturn(upgradeManager);
+        when(upgradeManager.shouldUpgrade(any(UpgradeType.class))).thenReturn(false);
+        when(upgradeManager.shouldUpgrade(UpgradeType.SQL_CHARSET_UTF8MB4)).thenReturn(true);
+
+        // WHEN – constructor will call checkStructure(), which will in turn call updateCharacterSet(...)
+        SQLDatabaseManager manager = createManagerFor(flavor);
+
+        // THEN – we at least expect the upgrade to be marked completed
+        verify(upgradeManager, atLeastOnce()).setUpgradeCompleted(UpgradeType.SQL_CHARSET_UTF8MB4);
+
+        manager.onDisable();
+
+        // Restore default behavior for other tests: no upgrades
+        when(upgradeManager.shouldUpgrade(any(UpgradeType.class))).thenReturn(false);
+    }
+
+    @ParameterizedTest(name = "{0} - when all upgrades are required, all upgrade helpers execute")
+    @MethodSource("dbFlavors")
+    void whenAllUpgradesRequiredShouldExecuteAllUpgradeHelpers(DbFlavor flavor) {
+        // GIVEN – clean schema
+        truncateAllCoreTables(flavor);
+
+        // GIVEN – every UpgradeType should be considered "needed"
+        reset(upgradeManager);
+        when(mcMMO.getUpgradeManager()).thenReturn(upgradeManager);
+        when(upgradeManager.shouldUpgrade(any(UpgradeType.class))).thenReturn(true);
+
+        // WHEN – constructor will call checkStructure() which loops all UpgradeType values
+        SQLDatabaseManager databaseManager = createManagerFor(flavor);
+
+        // THEN – at least one call to mark some upgrades complete (and in practice, many)
+        verify(upgradeManager, atLeastOnce()).setUpgradeCompleted(any(UpgradeType.class));
+
+        databaseManager.onDisable();
+
+        // Restore default for other tests
+        when(upgradeManager.shouldUpgrade(any(UpgradeType.class))).thenReturn(false);
+    }
+
+    // ------------------------------------------------------------------------
+    // 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();
+    }
+
+    @ParameterizedTest(name = "{0} - readRank for unknown user returns empty map")
+    @MethodSource("dbFlavors")
+    void whenReadingRankForUnknownUserShouldReturnEmptyMap(DbFlavor flavor) {
+        truncateAllCoreTables(flavor);
+        SQLDatabaseManager databaseManager = createManagerFor(flavor);
+
+        try {
+            Map<PrimarySkillType, Integer> ranks = databaseManager.readRank("ghost_" + flavor.name().toLowerCase());
+
+            assertThat(ranks)
+                    .as("Unknown user should yield an empty rank map")
+                    .isEmpty();
+        } finally {
+            databaseManager.onDisable();
+        }
+    }
+
+    @ParameterizedTest(name = "{0} - readRank for zero-skill user returns empty map")
+    @MethodSource("dbFlavors")
+    void whenReadingRankForZeroSkillUserShouldReturnEmptyMap(DbFlavor flavor) {
+        truncateAllCoreTables(flavor);
+        SQLDatabaseManager databaseManager = createManagerFor(flavor);
+
+        String zeroName = "zeros_" + flavor.name().toLowerCase();
+        UUID zeroUuid = UUID.randomUUID();
+
+        try {
+            // newUser -> all skills 0, total 0
+            databaseManager.newUser(zeroName, zeroUuid);
+            PlayerProfile zeroProfile = databaseManager.loadPlayerProfile(zeroUuid);
+            assertThat(databaseManager.saveUser(zeroProfile)).isTrue();
+
+            // Also create a powered user for sanity; zero user still should not be ranked
+            String poweredName = "nonzero_" + flavor.name().toLowerCase();
+            UUID poweredUuid = UUID.randomUUID();
+            createUserWithUniformNonChildSkills(databaseManager, poweredName, poweredUuid, 100);
+
+            Map<PrimarySkillType, Integer> ranks = databaseManager.readRank(zeroName);
+
+            assertThat(ranks)
+                    .as("User with all skills at 0 should not have any rank entries")
+                    .isEmpty();
+        } finally {
+            databaseManager.onDisable();
+        }
+    }
+
+    @ParameterizedTest(name = "{0} - readRank with a single user → rank 1 for all non-child skills")
+    @MethodSource("dbFlavors")
+    void whenSingleUserShouldBeRankOneForAllNonChildSkills(DbFlavor flavor) {
+        truncateAllCoreTables(flavor);
+        SQLDatabaseManager databaseManager = createManagerFor(flavor);
+
+        String soloName = "solo_" + flavor.name().toLowerCase();
+        UUID soloUuid = UUID.randomUUID();
+
+        try {
+            // All non-child skills = 50
+            createUserWithUniformNonChildSkills(databaseManager, soloName, soloUuid, 50);
+
+            Map<PrimarySkillType, Integer> ranks = databaseManager.readRank(soloName);
+
+            for (PrimarySkillType type : PrimarySkillType.values()) {
+                if (SkillTools.isChildSkill(type)) {
+                    assertThat(ranks.get(type))
+                            .as("Child skill %s should have no rank", type)
+                            .isNull();
+                } else {
+                    assertThat(ranks.get(type))
+                            .as("Solo player should be rank 1 for skill %s", type)
+                            .isEqualTo(1);
+                }
+            }
+
+            // Total rank (null key) should also be 1
+            assertThat(ranks.get(null))
+                    .as("Solo player total rank should be 1")
+                    .isEqualTo(1);
+        } finally {
+            databaseManager.onDisable();
+        }
+    }
+
+    @ParameterizedTest(name = "{0} - readRank alphabetical tiebreaker with only equal-skill users")
+    @MethodSource("dbFlavors")
+    void whenEqualSkillUsersOnlyShouldUseAlphabeticalTiebreaker(DbFlavor flavor) {
+        truncateAllCoreTables(flavor);
+        SQLDatabaseManager databaseManager = createManagerFor(flavor);
+
+        String nameA = "aaa_" + flavor.name().toLowerCase();
+        String nameB = "bbb_" + flavor.name().toLowerCase();
+        String nameC = "ccc_" + flavor.name().toLowerCase();
+
+        UUID uuidA = UUID.randomUUID();
+        UUID uuidB = UUID.randomUUID();
+        UUID uuidC = UUID.randomUUID();
+
+        try {
+            // For simplicity, set only MINING and let total = mining
+            Map<PrimarySkillType, Integer> skillMap = Map.of(PrimarySkillType.MINING, 100);
+
+            createUserWithSkills(databaseManager, nameA, uuidA, skillMap);
+            createUserWithSkills(databaseManager, nameB, uuidB, skillMap);
+            createUserWithSkills(databaseManager, nameC, uuidC, skillMap);
+
+            Map<PrimarySkillType, Integer> ranksA = databaseManager.readRank(nameA);
+            Map<PrimarySkillType, Integer> ranksB = databaseManager.readRank(nameB);
+            Map<PrimarySkillType, Integer> ranksC = databaseManager.readRank(nameC);
+
+            // Mining ranks: alphabetical order
+            assertThat(ranksA.get(PrimarySkillType.MINING)).isEqualTo(1);
+            assertThat(ranksB.get(PrimarySkillType.MINING)).isEqualTo(2);
+            assertThat(ranksC.get(PrimarySkillType.MINING)).isEqualTo(3);
+
+            // Total ranks behave the same in this setup (total == mining)
+            assertThat(ranksA.get(null)).isEqualTo(1);
+            assertThat(ranksB.get(null)).isEqualTo(2);
+            assertThat(ranksC.get(null)).isEqualTo(3);
+        } finally {
+            databaseManager.onDisable();
+        }
+    }
+
+    @ParameterizedTest(name = "{0} - readRank tie group is offset by number of higher players")
+    @MethodSource("dbFlavors")
+    void whenEqualSkillUsersHaveHigherPlayerShouldOffsetByHigherCount(DbFlavor flavor) {
+        truncateAllCoreTables(flavor);
+        SQLDatabaseManager databaseManager = createManagerFor(flavor);
+
+        String higherName = "zoe_" + flavor.name().toLowerCase();
+        String nameA = "aaa2_" + flavor.name().toLowerCase();
+        String nameB = "bbb2_" + flavor.name().toLowerCase();
+        String nameC = "ccc2_" + flavor.name().toLowerCase();
+
+        UUID uuidHigher = UUID.randomUUID();
+        UUID uuidA = UUID.randomUUID();
+        UUID uuidB = UUID.randomUUID();
+        UUID uuidC = UUID.randomUUID();
+
+        try {
+            // Higher player
+            createUserWithSkills(
+                    databaseManager,
+                    higherName,
+                    uuidHigher,
+                    Map.of(PrimarySkillType.MINING, 200)
+            );
+
+            // Tie group
+            Map<PrimarySkillType, Integer> tieSkills = Map.of(PrimarySkillType.MINING, 100);
+            createUserWithSkills(databaseManager, nameA, uuidA, tieSkills);
+            createUserWithSkills(databaseManager, nameB, uuidB, tieSkills);
+            createUserWithSkills(databaseManager, nameC, uuidC, tieSkills);
+
+            Map<PrimarySkillType, Integer> higherRanks = databaseManager.readRank(higherName);
+            Map<PrimarySkillType, Integer> ranksA = databaseManager.readRank(nameA);
+            Map<PrimarySkillType, Integer> ranksB = databaseManager.readRank(nameB);
+            Map<PrimarySkillType, Integer> ranksC = databaseManager.readRank(nameC);
+
+            // Higher player is rank 1
+            assertThat(higherRanks.get(PrimarySkillType.MINING)).isEqualTo(1);
+
+            // Others follow in alphabetical order, offset by 1
+            assertThat(ranksA.get(PrimarySkillType.MINING)).isEqualTo(2);
+            assertThat(ranksB.get(PrimarySkillType.MINING)).isEqualTo(3);
+            assertThat(ranksC.get(PrimarySkillType.MINING)).isEqualTo(4);
+        } finally {
+            databaseManager.onDisable();
+        }
+    }
+
+    @ParameterizedTest(name = "{0} - readRank per-skill vs total ranking can differ")
+    @MethodSource("dbFlavors")
+    void whenDifferentSkillDistributionsShouldComputePerSkillAndTotalRanksSeparately(DbFlavor flavor) {
+        truncateAllCoreTables(flavor);
+        SQLDatabaseManager databaseManager = createManagerFor(flavor);
+
+        String alphaName = "alpha_" + flavor.name().toLowerCase();
+        String bravoName = "bravo_" + flavor.name().toLowerCase();
+        String charlieName = "charlie_" + flavor.name().toLowerCase();
+
+        UUID alphaUuid = UUID.randomUUID();
+        UUID bravoUuid = UUID.randomUUID();
+        UUID charlieUuid = UUID.randomUUID();
+
+        try {
+            // alpha: mining 100, fishing 0
+            createUserWithSkills(
+                    databaseManager,
+                    alphaName,
+                    alphaUuid,
+                    Map.of(PrimarySkillType.MINING, 100)
+            );
+
+            // bravo: mining 50, fishing 200
+            createUserWithSkills(
+                    databaseManager,
+                    bravoName,
+                    bravoUuid,
+                    Map.of(
+                            PrimarySkillType.MINING, 50,
+                            PrimarySkillType.FISHING, 200
+                    )
+            );
+
+            // charlie: mining 75, fishing 50
+            createUserWithSkills(
+                    databaseManager,
+                    charlieName,
+                    charlieUuid,
+                    Map.of(
+                            PrimarySkillType.MINING, 75,
+                            PrimarySkillType.FISHING, 50
+                    )
+            );
+
+            Map<PrimarySkillType, Integer> alphaRanks = databaseManager.readRank(alphaName);
+            Map<PrimarySkillType, Integer> bravoRanks = databaseManager.readRank(bravoName);
+            Map<PrimarySkillType, Integer> charlieRanks = databaseManager.readRank(charlieName);
+
+            // --- Mining (100 > 75 > 50) ---
+            assertThat(alphaRanks.get(PrimarySkillType.MINING)).isEqualTo(1);
+            assertThat(charlieRanks.get(PrimarySkillType.MINING)).isEqualTo(2);
+            assertThat(bravoRanks.get(PrimarySkillType.MINING)).isEqualTo(3);
+
+            // --- Fishing (200 > 50 > 0) ---
+            // alpha has 0 -> no rank entry for fishing
+            assertThat(alphaRanks.get(PrimarySkillType.FISHING)).isNull();
+
+            assertThat(bravoRanks.get(PrimarySkillType.FISHING)).isEqualTo(1);
+            assertThat(charlieRanks.get(PrimarySkillType.FISHING)).isEqualTo(2);
+
+            // --- Total: alpha 100, bravo 250, charlie 125 ---
+            assertThat(bravoRanks.get(null)).isEqualTo(1);   // 250 highest
+            assertThat(charlieRanks.get(null)).isEqualTo(2); // 125
+            assertThat(alphaRanks.get(null)).isEqualTo(3);   // 100
+        } finally {
+            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();
+        }
+    }
+
+    // --------------------------------------------------------------------------
+    // Convert Users Tests
+    // --------------------------------------------------------------------------
+
+    @ParameterizedTest(name = "{0} - convertUsers migrates all stored users")
+    @MethodSource("dbFlavors")
+    void whenConvertingUsersShouldSaveEachStoredUserToDestination(DbFlavor flavor) {
+        // GIVEN
+        truncateAllCoreTables(flavor);
+        SQLDatabaseManager sourceManager = createManagerFor(flavor);
+
+        String userA = "convert_user_a_" + flavor.name().toLowerCase();
+        String userB = "convert_user_b_" + flavor.name().toLowerCase();
+        sourceManager.newUser(userA, new UUID(1L, 2L));
+        sourceManager.newUser(userB, new UUID(3L, 4L));
+
+        DatabaseManager destination = mock(DatabaseManager.class);
+        when(destination.saveUser(any(PlayerProfile.class))).thenReturn(true);
+
+        // WHEN
+        sourceManager.convertUsers(destination);
+
+        // THEN – destination.saveUser(...) called once per stored user
+        ArgumentCaptor<PlayerProfile> profileCaptor = ArgumentCaptor.forClass(PlayerProfile.class);
+        verify(destination, times(2)).saveUser(profileCaptor.capture());
+
+        assertThat(profileCaptor.getAllValues())
+                .extracting(PlayerProfile::getPlayerName)
+                .containsExactlyInAnyOrder(userA, userB);
+
+        sourceManager.onDisable();
+    }
+
+    @ParameterizedTest(name = "{0} - convertUsers on empty database does nothing")
+    @MethodSource("dbFlavors")
+    void whenConvertingUsersWithNoStoredUsersShouldNotCallDestination(DbFlavor flavor) {
+        // GIVEN
+        truncateAllCoreTables(flavor);
+        SQLDatabaseManager sourceManager = createManagerFor(flavor);
+
+        DatabaseManager destination = mock(DatabaseManager.class);
+        when(destination.saveUser(any(PlayerProfile.class))).thenReturn(true);
+
+        // WHEN
+        sourceManager.convertUsers(destination);
+
+        // THEN
+        verify(destination, times(0)).saveUser(any(PlayerProfile.class));
+
+        sourceManager.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();
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // Helpers for readRank tests
+    // ------------------------------------------------------------------------
+
+    private void createUserWithUniformNonChildSkills(SQLDatabaseManager manager,
+            String name,
+            UUID uuid,
+            int level) {
+        manager.newUser(name, uuid);
+        PlayerProfile profile = manager.loadPlayerProfile(uuid);
+        for (PrimarySkillType type : PrimarySkillType.values()) {
+            if (SkillTools.isChildSkill(type)) {
+                continue;
+            }
+            profile.modifySkill(type, level);
+        }
+        assertThat(manager.saveUser(profile)).isTrue();
+    }
+
+    private void createUserWithSkills(SQLDatabaseManager manager,
+            String name,
+            UUID uuid,
+            Map<PrimarySkillType, Integer> levels) {
+        manager.newUser(name, uuid);
+        PlayerProfile profile = manager.loadPlayerProfile(uuid);
+        for (Map.Entry<PrimarySkillType, Integer> e : levels.entrySet()) {
+            // modifySkill adds; starting level is 0 in tests
+            profile.modifySkill(e.getKey(), e.getValue());
+        }
+        assertThat(manager.saveUser(profile)).isTrue();
+    }
+
+}

+ 371 - 0
src/test/java/com/gmail/nossr50/util/skills/SkillToolsTest.java

@@ -0,0 +1,371 @@
+package com.gmail.nossr50.util.skills;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.gmail.nossr50.config.GeneralConfig;
+import com.gmail.nossr50.datatypes.skills.PrimarySkillType;
+import com.gmail.nossr50.datatypes.skills.SubSkillType;
+import com.gmail.nossr50.datatypes.skills.SuperAbilityType;
+import com.gmail.nossr50.datatypes.skills.ToolType;
+import com.gmail.nossr50.locale.LocaleLoader;
+import com.gmail.nossr50.mcMMO;
+import com.gmail.nossr50.util.compat.CompatibilityManager;
+import com.gmail.nossr50.util.platform.MinecraftGameVersion;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.TestInstance.Lifecycle;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+@TestInstance(Lifecycle.PER_CLASS)
+class SkillToolsTest {
+
+    private static final @NotNull Logger logger = Logger.getLogger(Logger.GLOBAL_LOGGER_NAME);
+
+    private static MockedStatic<mcMMO> mockedMcMMO;
+    private static MockedStatic<LocaleLoader> mockedLocaleLoader;
+
+    private GeneralConfig generalConfig;
+    private CompatibilityManager compatibilityManager;
+
+    @BeforeAll
+    void setUpAll() {
+        // Static mcMMO + LocaleLoader mocks
+        mockedMcMMO = Mockito.mockStatic(mcMMO.class);
+        mockedLocaleLoader = Mockito.mockStatic(LocaleLoader.class);
+
+        // Plugin instance
+        mcMMO.p = mock(mcMMO.class);
+        when(mcMMO.p.getLogger()).thenReturn(logger);
+
+        // General config
+        generalConfig = mock(GeneralConfig.class);
+        when(mcMMO.p.getGeneralConfig()).thenReturn(generalConfig);
+        when(generalConfig.getLocale()).thenReturn("en_US");
+
+        // Compatibility manager + game version
+        compatibilityManager = mock(CompatibilityManager.class);
+        when(mcMMO.getCompatibilityManager()).thenReturn(compatibilityManager);
+
+        // LocaleLoader – just echo key back to keep things simple/deterministic
+        mockedLocaleLoader.when(() -> LocaleLoader.getString(anyString()))
+                .thenAnswer(invocation -> invocation.getArgument(0));
+    }
+
+    @AfterAll
+    void tearDownAll() {
+        mockedLocaleLoader.close();
+        mockedMcMMO.close();
+    }
+
+    private SkillTools newSkillToolsForVersion(int major, int minor, int patch) throws Exception {
+        when(compatibilityManager.getMinecraftGameVersion())
+                .thenReturn(new MinecraftGameVersion(major, minor, patch));
+        return new SkillTools(mcMMO.p);
+    }
+
+    // ------------------------------------------------------------------------
+    // NON_CHILD_SKILLS / isChildSkill / CHILD_SKILLS
+    // ------------------------------------------------------------------------
+
+    @Test
+    void nonChildSkillsShouldContainAllPrimarySkillsExceptSalvageAndSmelting() {
+        List<PrimarySkillType> expected = Arrays.stream(PrimarySkillType.values())
+                .filter(t -> t != PrimarySkillType.SALVAGE && t != PrimarySkillType.SMELTING)
+                .collect(Collectors.toList());
+
+        assertThat(SkillTools.NON_CHILD_SKILLS)
+                .containsExactlyInAnyOrderElementsOf(expected);
+    }
+
+    @Test
+    void isChildSkillShouldReturnTrueOnlyForSalvageAndSmelting() {
+        for (PrimarySkillType type : PrimarySkillType.values()) {
+            boolean isChild = SkillTools.isChildSkill(type);
+
+            if (type == PrimarySkillType.SALVAGE || type == PrimarySkillType.SMELTING) {
+                assertThat(isChild)
+                        .as("%s should be considered a child skill", type)
+                        .isTrue();
+            } else {
+                assertThat(isChild)
+                        .as("%s should NOT be considered a child skill", type)
+                        .isFalse();
+            }
+        }
+    }
+
+    @Test
+    void childSkillsListShouldMatchIsChildSkillClassification() throws Exception {
+        SkillTools skillTools = newSkillToolsForVersion(1, 21, 11);
+
+        List<PrimarySkillType> expectedChildren = Arrays.stream(PrimarySkillType.values())
+                .filter(SkillTools::isChildSkill)
+                .collect(Collectors.toList());
+
+        assertThat(skillTools.getChildSkills())
+                .containsExactlyInAnyOrderElementsOf(expectedChildren);
+    }
+
+    // ------------------------------------------------------------------------
+    // Child skill parents (SALVAGE_PARENTS / SMELTING_PARENTS / getChildSkillParents)
+    // ------------------------------------------------------------------------
+
+    @Test
+    void childSkillParentsShouldMatchStaticParentLists() throws Exception {
+        SkillTools skillTools = newSkillToolsForVersion(1, 21, 11);
+
+        assertThat(skillTools.getChildSkillParents(PrimarySkillType.SALVAGE))
+                .as("SALVAGE parents")
+                .containsExactlyElementsOf(SkillTools.SALVAGE_PARENTS);
+
+        assertThat(skillTools.getChildSkillParents(PrimarySkillType.SMELTING))
+                .as("SMELTING parents")
+                .containsExactlyElementsOf(SkillTools.SMELTING_PARENTS);
+    }
+
+    @Test
+    void getChildSkillParentsShouldThrowForNonChildSkill() throws Exception {
+        SkillTools skillTools = newSkillToolsForVersion(1, 21, 11);
+
+        assertThatThrownBy(() -> skillTools.getChildSkillParents(PrimarySkillType.MINING))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("is not a child skill");
+    }
+
+    // ------------------------------------------------------------------------
+    // Super ability ↔ primary skill relationships
+    // ------------------------------------------------------------------------
+
+    @Test
+    void superAbilityParentMappingShouldMatchDefinedSwitch() throws Exception {
+        SkillTools skillTools = newSkillToolsForVersion(1, 21, 11);
+
+        assertThat(skillTools.getPrimarySkillBySuperAbility(SuperAbilityType.BERSERK))
+                .isEqualTo(PrimarySkillType.UNARMED);
+        assertThat(skillTools.getPrimarySkillBySuperAbility(SuperAbilityType.GREEN_TERRA))
+                .isEqualTo(PrimarySkillType.HERBALISM);
+        assertThat(skillTools.getPrimarySkillBySuperAbility(SuperAbilityType.TREE_FELLER))
+                .isEqualTo(PrimarySkillType.WOODCUTTING);
+        assertThat(skillTools.getPrimarySkillBySuperAbility(SuperAbilityType.SUPER_BREAKER))
+                .isEqualTo(PrimarySkillType.MINING);
+        assertThat(skillTools.getPrimarySkillBySuperAbility(SuperAbilityType.BLAST_MINING))
+                .isEqualTo(PrimarySkillType.MINING);
+        assertThat(skillTools.getPrimarySkillBySuperAbility(SuperAbilityType.SKULL_SPLITTER))
+                .isEqualTo(PrimarySkillType.AXES);
+        assertThat(skillTools.getPrimarySkillBySuperAbility(SuperAbilityType.SERRATED_STRIKES))
+                .isEqualTo(PrimarySkillType.SWORDS);
+        assertThat(skillTools.getPrimarySkillBySuperAbility(SuperAbilityType.GIGA_DRILL_BREAKER))
+                .isEqualTo(PrimarySkillType.EXCAVATION);
+        assertThat(skillTools.getPrimarySkillBySuperAbility(SuperAbilityType.SUPER_SHOTGUN))
+                .isEqualTo(PrimarySkillType.CROSSBOWS);
+        assertThat(skillTools.getPrimarySkillBySuperAbility(SuperAbilityType.TRIDENTS_SUPER_ABILITY))
+                .isEqualTo(PrimarySkillType.TRIDENTS);
+        assertThat(skillTools.getPrimarySkillBySuperAbility(SuperAbilityType.EXPLOSIVE_SHOT))
+                .isEqualTo(PrimarySkillType.ARCHERY);
+        assertThat(skillTools.getPrimarySkillBySuperAbility(SuperAbilityType.MACES_SUPER_ABILITY))
+                .isEqualTo(PrimarySkillType.MACES);
+        assertThat(skillTools.getPrimarySkillBySuperAbility(SuperAbilityType.SPEARS_SUPER_ABILITY))
+                .isEqualTo(PrimarySkillType.SPEARS);
+    }
+
+    @Test
+    void mainActivatedAbilityChildMapShouldOmitBlastMiningAndMapOthersBackToAbility() throws Exception {
+        SkillTools skillTools = newSkillToolsForVersion(1, 21, 11);
+
+        // All super abilities EXCEPT BLAST_MINING should be discoverable via getSuperAbility()
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.MINING))
+                .as("MINING should not expose BLAST_MINING as the 'main' tool-readied ability")
+                .isEqualTo(SuperAbilityType.SUPER_BREAKER);
+
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.UNARMED))
+                .isEqualTo(SuperAbilityType.BERSERK);
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.HERBALISM))
+                .isEqualTo(SuperAbilityType.GREEN_TERRA);
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.WOODCUTTING))
+                .isEqualTo(SuperAbilityType.TREE_FELLER);
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.AXES))
+                .isEqualTo(SuperAbilityType.SKULL_SPLITTER);
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.SWORDS))
+                .isEqualTo(SuperAbilityType.SERRATED_STRIKES);
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.EXCAVATION))
+                .isEqualTo(SuperAbilityType.GIGA_DRILL_BREAKER);
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.CROSSBOWS))
+                .isEqualTo(SuperAbilityType.SUPER_SHOTGUN);
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.TRIDENTS))
+                .isEqualTo(SuperAbilityType.TRIDENTS_SUPER_ABILITY);
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.ARCHERY))
+                .isEqualTo(SuperAbilityType.EXPLOSIVE_SHOT);
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.MACES))
+                .isEqualTo(SuperAbilityType.MACES_SUPER_ABILITY);
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.SPEARS))
+                .isEqualTo(SuperAbilityType.SPEARS_SUPER_ABILITY);
+
+        // Skills without a main activated ability should return null
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.REPAIR)).isNull();
+        assertThat(skillTools.getSuperAbility(PrimarySkillType.FISHING)).isNull();
+    }
+
+    // ------------------------------------------------------------------------
+    // Sub-skill → primary-skill mapping (name prefix convention)
+    // ------------------------------------------------------------------------
+
+    @Test
+    void primarySkillBySubSkillShouldFollowNamePrefixConvention() throws Exception {
+        SkillTools skillTools = newSkillToolsForVersion(1, 21, 11);
+
+        for (SubSkillType sub : SubSkillType.values()) {
+            PrimarySkillType parent = skillTools.getPrimarySkillBySubSkill(sub);
+
+            assertThat(parent)
+                    .as("SubSkill %s should have a parent PrimarySkillType", sub)
+                    .isNotNull();
+
+            String subName = sub.name().toUpperCase(Locale.ENGLISH);
+            String parentPrefix = parent.name().toUpperCase(Locale.ENGLISH);
+
+            assertThat(subName.startsWith(parentPrefix))
+                    .as("SubSkill %s should start with its parent skill name %s", subName, parentPrefix)
+                    .isTrue();
+        }
+    }
+
+    // ------------------------------------------------------------------------
+    // primarySkillToolMap
+    // ------------------------------------------------------------------------
+
+    @Test
+    void primarySkillToolTypeMappingShouldMatchDefinition() throws Exception {
+        SkillTools skillTools = newSkillToolsForVersion(1, 21, 11);
+
+        assertThat(skillTools.getPrimarySkillToolType(PrimarySkillType.AXES))
+                .isEqualTo(ToolType.AXE);
+        assertThat(skillTools.getPrimarySkillToolType(PrimarySkillType.WOODCUTTING))
+                .isEqualTo(ToolType.AXE);
+        assertThat(skillTools.getPrimarySkillToolType(PrimarySkillType.UNARMED))
+                .isEqualTo(ToolType.FISTS);
+        assertThat(skillTools.getPrimarySkillToolType(PrimarySkillType.SWORDS))
+                .isEqualTo(ToolType.SWORD);
+        assertThat(skillTools.getPrimarySkillToolType(PrimarySkillType.EXCAVATION))
+                .isEqualTo(ToolType.SHOVEL);
+        assertThat(skillTools.getPrimarySkillToolType(PrimarySkillType.HERBALISM))
+                .isEqualTo(ToolType.HOE);
+        assertThat(skillTools.getPrimarySkillToolType(PrimarySkillType.MINING))
+                .isEqualTo(ToolType.PICKAXE);
+
+        // And any skill not explicitly mapped should currently return null
+        assertThat(skillTools.getPrimarySkillToolType(PrimarySkillType.FISHING)).isNull();
+        assertThat(skillTools.getPrimarySkillToolType(PrimarySkillType.TAMING)).isNull();
+    }
+
+    // ------------------------------------------------------------------------
+    // Combat / Gathering / Misc groupings by Minecraft version
+    // ------------------------------------------------------------------------
+
+    @Test
+    void combatGatheringMiscGroupingsShouldMatchDefinitionForModernSpearsAndMacesVersion()
+            throws Exception {
+        SkillTools skillTools = newSkillToolsForVersion(1, 21, 11);
+
+        assertThat(skillTools.getCombatSkills())
+                .containsExactly(
+                        PrimarySkillType.ARCHERY,
+                        PrimarySkillType.AXES,
+                        PrimarySkillType.CROSSBOWS,
+                        PrimarySkillType.MACES,
+                        PrimarySkillType.SWORDS,
+                        PrimarySkillType.SPEARS,
+                        PrimarySkillType.TAMING,
+                        PrimarySkillType.TRIDENTS,
+                        PrimarySkillType.UNARMED
+                );
+
+        assertThat(skillTools.getGatheringSkills())
+                .containsExactly(
+                        PrimarySkillType.EXCAVATION,
+                        PrimarySkillType.FISHING,
+                        PrimarySkillType.HERBALISM,
+                        PrimarySkillType.MINING,
+                        PrimarySkillType.WOODCUTTING
+                );
+
+        assertThat(skillTools.getMiscSkills())
+                .containsExactly(
+                        PrimarySkillType.ACROBATICS,
+                        PrimarySkillType.ALCHEMY,
+                        PrimarySkillType.REPAIR,
+                        PrimarySkillType.SALVAGE,
+                        PrimarySkillType.SMELTING
+                );
+    }
+
+    @Test
+    void combatSkillsShouldMatchDefinitionForVersionWithMacesButWithoutSpears() throws Exception {
+        SkillTools skillTools = newSkillToolsForVersion(1, 21, 0);
+
+        assertThat(skillTools.getCombatSkills())
+                .containsExactly(
+                        PrimarySkillType.ARCHERY,
+                        PrimarySkillType.AXES,
+                        PrimarySkillType.CROSSBOWS,
+                        PrimarySkillType.MACES,
+                        PrimarySkillType.SWORDS,
+                        PrimarySkillType.TAMING,
+                        PrimarySkillType.TRIDENTS,
+                        PrimarySkillType.UNARMED
+                );
+    }
+
+    @Test
+    void combatSkillsShouldMatchDefinitionForVersionWithoutMacesOrSpears() throws Exception {
+        SkillTools skillTools = newSkillToolsForVersion(1, 20, 4);
+
+        assertThat(skillTools.getCombatSkills())
+                .containsExactly(
+                        PrimarySkillType.ARCHERY,
+                        PrimarySkillType.AXES,
+                        PrimarySkillType.CROSSBOWS,
+                        PrimarySkillType.SWORDS,
+                        PrimarySkillType.TAMING,
+                        PrimarySkillType.TRIDENTS,
+                        PrimarySkillType.UNARMED
+                );
+    }
+
+    // ------------------------------------------------------------------------
+    // LOCALIZED_SKILL_NAMES basic sanity (size + uniqueness, not content)
+    // ------------------------------------------------------------------------
+
+    @Test
+    void localizedSkillNamesShouldContainOneEntryPerPrimarySkillAndBeSorted() throws Exception {
+        SkillTools skillTools = newSkillToolsForVersion(1, 21, 11);
+
+        List<String> names = new ArrayList<>(skillTools.LOCALIZED_SKILL_NAMES);
+
+        // One per PrimarySkillType
+        assertThat(names).hasSize(PrimarySkillType.values().length);
+
+        // No duplicates
+        assertThat(new HashSet<>(names)).hasSize(names.size());
+
+        // Sorted ascending
+        List<String> sorted = new ArrayList<>(names);
+        Collections.sort(sorted);
+        assertThat(names).isEqualTo(sorted);
+    }
+}

+ 3 - 3
src/test/resources/healthydb.users

@@ -1,3 +1,3 @@
-nossr50:1:IGNORED:IGNORED:10:2:20:3:4:5:6:7:8:9:10:30:40:50:60:70:80:90:100:IGNORED:11:110:111:222:333:444:555:666:777:IGNORED:12:120:888:IGNORED:HEARTS:13:130:588fe472-1c82-4c4e-9aa1-7eefccb277e3:1111:999:2020:140:14:150:15:1111:2222:3333:160:16:4444:
-mrfloris:2420:::0:2452:0:1983:1937:1790:3042:1138:3102:2408:3411:0:0:0:0:0:0:0:0::642:0:1617583171:0:1617165043:0:1617583004:1617563189:1616785408::2184:0:0:1617852413:HEARTS:415:0:631e3896-da2a-4077-974b-d047859d76bc:5:1600906906:3030:0:0:0:0:0:0:0:0:0:0:
-powerless:0:::0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0::0:0:0:0:0:0:0:0:0::0:0:0:1337:HEARTS:0:0:e0d07db8-f7e8-43c7-9ded-864dfc6f3b7c:5:1600906906:4040:0:0:0:0:0:0:0:0:0:0:
+nossr50:1:IGNORED:IGNORED:10:2:20:3:4:5:6:7:8:9:10:30:40:50:60:70:80:90:100:IGNORED:11:110:111:222:333:444:555:666:777:IGNORED:12:120:888:IGNORED:HEARTS:13:130:588fe472-1c82-4c4e-9aa1-7eefccb277e3:1111:999:2020:140:14:150:15:1111:2222:3333:160:16:4444:170:17:5555:
+mrfloris:2420:::0:2452:0:1983:1937:1790:3042:1138:3102:2408:3411:0:0:0:0:0:0:0:0::642:0:1617583171:0:1617165043:0:1617583004:1617563189:1616785408::2184:0:0:1617852413:HEARTS:415:0:631e3896-da2a-4077-974b-d047859d76bc:5:1600906906:3030:0:0:0:0:0:0:0:0:0:0:0:0:0:
+powerless:0:::0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0::0:0:0:0:0:0:0:0:0::0:0:0:1337:HEARTS:0:0:e0d07db8-f7e8-43c7-9ded-864dfc6f3b7c:5:1600906906:4040:0:0:0:0:0:0:0:0:0:0:0:0:0:

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä