浏览代码

Spears update (#5236)

Spears update
Robert Alan Chapton 1 天之前
父节点
当前提交
b15365e978
共有 55 个文件被更改,包括 4728 次插入1812 次删除
  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
 Version 2.2.045
     Green Thumb now replants some crops it was failing to replant before (see notes)
     Green Thumb now replants some crops it was failing to replant before (see notes)
     Green Thumb now replants harvested plants faster
     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>
     <modelVersion>4.0.0</modelVersion>
     <groupId>com.gmail.nossr50.mcMMO</groupId>
     <groupId>com.gmail.nossr50.mcMMO</groupId>
     <artifactId>mcMMO</artifactId>
     <artifactId>mcMMO</artifactId>
-    <version>2.2.046-SNAPSHOT</version>
+    <version>2.2.046</version>
     <name>mcMMO</name>
     <name>mcMMO</name>
     <url>https://github.com/mcMMO-Dev/mcMMO</url>
     <url>https://github.com/mcMMO-Dev/mcMMO</url>
     <scm>
     <scm>
@@ -13,8 +15,8 @@
     </scm>
     </scm>
 
 
     <properties>
     <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.version>4.23.0</kyori.adventure.version>
         <kyori.adventure.platform.version>4.4.1-SNAPSHOT</kyori.adventure.platform.version>
         <kyori.adventure.platform.version>4.4.1-SNAPSHOT</kyori.adventure.platform.version>
         <kyori.option.version>1.1.0</kyori.option.version>
         <kyori.option.version>1.1.0</kyori.option.version>
@@ -182,11 +184,13 @@
                         </relocation>
                         </relocation>
                         <relocation>
                         <relocation>
                             <pattern>co.aikar.commands</pattern>
                             <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>
                         <relocation>
                         <relocation>
                             <pattern>co.aikar.locales</pattern>
                             <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>
                         <relocation>
                         <relocation>
                             <pattern>org.apache.commons.logging</pattern>
                             <pattern>org.apache.commons.logging</pattern>
@@ -194,7 +198,8 @@
                         </relocation>
                         </relocation>
                         <relocation>
                         <relocation>
                             <pattern>org.apache.juli</pattern>
                             <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>
                         <relocation>
                         <relocation>
                             <pattern>org.apache.tomcat</pattern>
                             <pattern>org.apache.tomcat</pattern>
@@ -385,11 +390,11 @@
             <version>3.0.2</version>
             <version>3.0.2</version>
             <scope>compile</scope>
             <scope>compile</scope>
         </dependency>
         </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>
         <dependency>
             <groupId>org.spigotmc</groupId>
             <groupId>org.spigotmc</groupId>
             <artifactId>spigot-api</artifactId>
             <artifactId>spigot-api</artifactId>
@@ -426,10 +431,76 @@
                 </exclusion>
                 </exclusion>
             </exclusions>
             </exclusions>
         </dependency>
         </dependency>
+        <!-- JUnit 5 -->
         <dependency>
         <dependency>
             <groupId>org.junit.jupiter</groupId>
             <groupId>org.junit.jupiter</groupId>
             <artifactId>junit-jupiter</artifactId>
             <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>
             <scope>test</scope>
         </dependency>
         </dependency>
         <dependency>
         <dependency>
@@ -447,7 +518,7 @@
         <dependency>
         <dependency>
             <groupId>org.apache.tomcat</groupId>
             <groupId>org.apache.tomcat</groupId>
             <artifactId>tomcat-jdbc</artifactId>
             <artifactId>tomcat-jdbc</artifactId>
-            <version>10.1.24</version>
+            <version>11.0.14</version>
             <scope>compile</scope>
             <scope>compile</scope>
         </dependency>
         </dependency>
         <dependency>
         <dependency>
@@ -458,7 +529,8 @@
         <dependency>
         <dependency>
             <groupId>com.google.guava</groupId>
             <groupId>com.google.guava</groupId>
             <artifactId>guava</artifactId>
             <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>
             <scope>compile</scope>
         </dependency>
         </dependency>
         <dependency>
         <dependency>
@@ -468,4 +540,15 @@
             <scope>compile</scope>
             <scope>compile</scope>
         </dependency>
         </dependency>
     </dependencies>
     </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>
 </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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.ACROBATICS);
                 PrimarySkillType.ACROBATICS);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.ALCHEMY);
                 PrimarySkillType.ALCHEMY);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.ARCHERY);
                 PrimarySkillType.ARCHERY);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         final List<Component> textComponents = new ArrayList<>();
         final List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.AXES);
                 PrimarySkillType.AXES);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.CROSSBOWS);
                 PrimarySkillType.CROSSBOWS);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.EXCAVATION);
                 PrimarySkillType.EXCAVATION);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.FISHING);
                 PrimarySkillType.FISHING);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.HERBALISM);
                 PrimarySkillType.HERBALISM);
 
 
         return textComponents;
         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);
         super(PrimarySkillType.MACES);
     }
     }
 
 
-    String crippleChanceToApply, crippleChanceToApplyLucky, crippleLengthAgainstPlayers, crippleLengthAgainstMobs;
+    String crippleChanceToApply, crippleChanceToApplyLucky, crippleLengthAgainstPlayers,
+            crippleLengthAgainstMobs;
 
 
     @Override
     @Override
     protected void dataCalculations(Player player, float skillValue) {
     protected void dataCalculations(Player player, float skillValue) {
@@ -33,7 +34,6 @@ public class MacesCommand extends SkillCommand {
                     MacesManager.getCrippleTickDuration(true) / 20.0D);
                     MacesManager.getCrippleTickDuration(true) / 20.0D);
             crippleLengthAgainstMobs = String.valueOf(
             crippleLengthAgainstMobs = String.valueOf(
                     MacesManager.getCrippleTickDuration(false) / 20.0D);
                     MacesManager.getCrippleTickDuration(false) / 20.0D);
-
             crippleChanceToApply =
             crippleChanceToApply =
                     mcMMO.p.getAdvancedConfig().getCrippleChanceToApplyOnHit(crippleRank) + "%";
                     mcMMO.p.getAdvancedConfig().getCrippleChanceToApplyOnHit(crippleRank) + "%";
             crippleChanceToApplyLucky = String.valueOf(
             crippleChanceToApplyLucky = String.valueOf(
@@ -77,7 +77,7 @@ public class MacesCommand extends SkillCommand {
     protected List<Component> getTextComponents(Player player) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.MACES);
                 PrimarySkillType.MACES);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.MINING);
                 PrimarySkillType.MINING);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.REPAIR);
                 PrimarySkillType.REPAIR);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.SALVAGE);
                 PrimarySkillType.SALVAGE);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.SMELTING);
                 PrimarySkillType.SMELTING);
 
 
         return textComponents;
         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 serratedStrikesLengthEndurance;
 
 
     private String rupturePureTickDamageAgainstPlayers, rupturePureTickDamageAgainstMobs,
     private String rupturePureTickDamageAgainstPlayers, rupturePureTickDamageAgainstMobs,
-            ruptureExplosionDamageAgainstPlayers, ruptureExplosionDamageAgainstMobs,
-            ruptureLengthSecondsAgainstPlayers, ruptureLengthSecondsAgainstMobs, ruptureChanceToApply, ruptureChanceToApplyLucky;
+            ruptureLengthSecondsAgainstPlayers, ruptureLengthSecondsAgainstMobs,
+            ruptureChanceToApply, ruptureChanceToApplyLucky;
 
 
     private boolean canCounter;
     private boolean canCounter;
     private boolean canSerratedStrike;
     private boolean canSerratedStrike;
@@ -56,11 +56,6 @@ public class SwordsCommand extends SkillCommand {
             rupturePureTickDamageAgainstMobs = String.valueOf(
             rupturePureTickDamageAgainstMobs = String.valueOf(
                     mcMMO.p.getAdvancedConfig().getRuptureTickDamage(false, ruptureRank));
                     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 =
             ruptureChanceToApply =
                     mcMMO.p.getAdvancedConfig().getRuptureChanceToApplyOnHit(ruptureRank) + "%";
                     mcMMO.p.getAdvancedConfig().getRuptureChanceToApplyOnHit(ruptureRank) + "%";
             ruptureChanceToApplyLucky = String.valueOf(
             ruptureChanceToApplyLucky = String.valueOf(
@@ -105,7 +100,6 @@ public class SwordsCommand extends SkillCommand {
 
 
             messages.add(LocaleLoader.getString("Swords.SubSkill.Rupture.Stat.TickDamage",
             messages.add(LocaleLoader.getString("Swords.SubSkill.Rupture.Stat.TickDamage",
                     rupturePureTickDamageAgainstPlayers, rupturePureTickDamageAgainstMobs));
                     rupturePureTickDamageAgainstPlayers, rupturePureTickDamageAgainstMobs));
-//            messages.add(LocaleLoader.getString("Swords.SubSkill.Rupture.Stat.ExplosionDamage", ruptureExplosionDamageAgainstPlayers, ruptureExplosionDamageAgainstMobs));
 
 
             messages.add(LocaleLoader.getString("Swords.Combat.Rupture.Note.Update.One"));
             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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.SWORDS);
                 PrimarySkillType.SWORDS);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents, this.skill);
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents, this.skill);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.TRIDENTS);
                 PrimarySkillType.TRIDENTS);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.UNARMED);
                 PrimarySkillType.UNARMED);
 
 
         return textComponents;
         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) {
     protected List<Component> getTextComponents(Player player) {
         List<Component> textComponents = new ArrayList<>();
         List<Component> textComponents = new ArrayList<>();
 
 
-        TextComponentFactory.getSubSkillTextComponents(player, textComponents,
+        TextComponentFactory.appendSubSkillTextComponents(player, textComponents,
                 PrimarySkillType.WOODCUTTING);
                 PrimarySkillType.WOODCUTTING);
 
 
         return textComponents;
         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 {
 public class AdvancedConfig extends BukkitConfig {
     int[] defaultCrippleValues = new int[]{10, 15, 20, 25};
     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) {
     public AdvancedConfig(File dataFolder) {
         super("advanced.yml", dataFolder);
         super("advanced.yml", dataFolder);
@@ -884,7 +885,17 @@ public class AdvancedConfig extends BukkitConfig {
 
 
     /* MACES */
     /* MACES */
     public double getCrippleChanceToApplyOnHit(int rank) {
     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);
         return config.getBoolean("MySQL.Server.SSL", true);
     }
     }
 
 
-    public boolean getMySQLDebug() {
-        return config.getBoolean("MySQL.Debug", false);
-    }
-
     public boolean getMySQLPublicKeyRetrieval() {
     public boolean getMySQLPublicKeyRetrieval() {
         return config.getBoolean("MySQL.Server.allowPublicKeyRetrieval", true);
         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");
                             : "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);
                 : 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_MACES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SERRATED_STRIKES;
 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_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_BREAKER;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SUPER_SHOTGUN;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SUPER_SHOTGUN;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_TREE_FELLER;
 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_MACES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_MINING;
 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_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_SWORDS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TAMING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TAMING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TRIDENTS;
 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_MACES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_MINING;
 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_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_SWORDS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TAMING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TAMING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TRIDENTS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TRIDENTS;
@@ -318,27 +321,26 @@ public class FlatFileDataProcessor {
             throws IndexOutOfBoundsException {
             throws IndexOutOfBoundsException {
         return switch (dataIndex) {
         return switch (dataIndex) {
             case USERNAME_INDEX ->
             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
             //Assumption: Used to be used for something, no longer used
             case 2, 3, 23, 33, HEALTHBAR, LEGACY_LAST_LOGIN -> ExpectedType.IGNORED;
             case 2, 3, 23, 33, HEALTHBAR, LEGACY_LAST_LOGIN -> ExpectedType.IGNORED;
             case SKILLS_MINING, SKILLS_REPAIR, SKILLS_UNARMED, SKILLS_HERBALISM, SKILLS_EXCAVATION,
             case SKILLS_MINING, SKILLS_REPAIR, SKILLS_UNARMED, SKILLS_HERBALISM, SKILLS_EXCAVATION,
                  SKILLS_ARCHERY,
                  SKILLS_ARCHERY,
                  SKILLS_SWORDS, SKILLS_AXES, SKILLS_WOODCUTTING, SKILLS_ACROBATICS, SKILLS_TAMING,
                  SKILLS_SWORDS, SKILLS_AXES, SKILLS_WOODCUTTING, SKILLS_ACROBATICS, SKILLS_TAMING,
                  SKILLS_FISHING,
                  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_GIGA_DRILL_BREAKER, COOLDOWN_TREE_FELLER, COOLDOWN_GREEN_TERRA,
                  COOLDOWN_SERRATED_STRIKES,
                  COOLDOWN_SERRATED_STRIKES,
                  COOLDOWN_SKULL_SPLITTER, COOLDOWN_SUPER_BREAKER, COOLDOWN_BLAST_MINING,
                  COOLDOWN_SKULL_SPLITTER, COOLDOWN_SUPER_BREAKER, COOLDOWN_BLAST_MINING,
                  SCOREBOARD_TIPS,
                  SCOREBOARD_TIPS,
                  COOLDOWN_CHIMAERA_WING, COOLDOWN_SUPER_SHOTGUN, COOLDOWN_TRIDENTS,
                  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,
             case EXP_MINING, EXP_WOODCUTTING, EXP_REPAIR, EXP_UNARMED, EXP_HERBALISM,
                  EXP_EXCAVATION, EXP_ARCHERY,
                  EXP_EXCAVATION, EXP_ARCHERY,
                  EXP_SWORDS, EXP_AXES, EXP_ACROBATICS, EXP_TAMING, EXP_FISHING, EXP_ALCHEMY,
                  EXP_SWORDS, EXP_AXES, EXP_ACROBATICS, EXP_TAMING, EXP_FISHING, EXP_ALCHEMY,
                  EXP_CROSSBOWS,
                  EXP_CROSSBOWS,
-                 EXP_TRIDENTS, EXP_MACES -> ExpectedType.FLOAT;
+                 EXP_TRIDENTS, EXP_MACES, EXP_SPEARS -> ExpectedType.FLOAT;
             case UUID_INDEX -> ExpectedType.UUID;
             case UUID_INDEX -> ExpectedType.UUID;
             case OVERHAUL_LAST_LOGIN -> ExpectedType.LONG;
             case OVERHAUL_LAST_LOGIN -> ExpectedType.LONG;
             default -> throw new IndexOutOfBoundsException();
             default -> throw new IndexOutOfBoundsException();

文件差异内容过多而无法显示
+ 322 - 475
src/main/java/com/gmail/nossr50/database/FlatFileDatabaseManager.java


文件差异内容过多而无法显示
+ 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_MACES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SERRATED_STRIKES;
 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_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_BREAKER;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SUPER_SHOTGUN;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SUPER_SHOTGUN;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_TREE_FELLER;
 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_MACES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_MINING;
 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_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_SWORDS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TAMING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TAMING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TRIDENTS;
 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_MACES;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_MINING;
 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_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_SWORDS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TAMING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TAMING;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TRIDENTS;
 import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TRIDENTS;
@@ -114,18 +117,16 @@ public class FlatFileDataUtil {
             throws IndexOutOfBoundsException {
             throws IndexOutOfBoundsException {
         //TODO: Add UUID recovery? Might not even be worth it.
         //TODO: Add UUID recovery? Might not even be worth it.
         return switch (index) {
         return switch (index) {
+            //We'll keep using this value for legacy compatibility reasons (not sure if needed but don't care)
             case USERNAME_INDEX ->
             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
             //Assumption: Used to be used for something, no longer used
             case 2, 3, 23, 33, LEGACY_LAST_LOGIN, HEALTHBAR -> "IGNORED";
             case 2, 3, 23, 33, LEGACY_LAST_LOGIN, HEALTHBAR -> "IGNORED";
             case SKILLS_MINING, SKILLS_REPAIR, SKILLS_UNARMED, SKILLS_HERBALISM, SKILLS_EXCAVATION,
             case SKILLS_MINING, SKILLS_REPAIR, SKILLS_UNARMED, SKILLS_HERBALISM, SKILLS_EXCAVATION,
                  SKILLS_ARCHERY,
                  SKILLS_ARCHERY,
                  SKILLS_SWORDS, SKILLS_AXES, SKILLS_WOODCUTTING, SKILLS_ACROBATICS, SKILLS_TAMING,
                  SKILLS_SWORDS, SKILLS_AXES, SKILLS_WOODCUTTING, SKILLS_ACROBATICS, SKILLS_TAMING,
                  SKILLS_FISHING,
                  SKILLS_FISHING,
-                 SKILLS_ALCHEMY, SKILLS_CROSSBOWS, SKILLS_TRIDENTS, SKILLS_MACES ->
+                 SKILLS_ALCHEMY, SKILLS_CROSSBOWS, SKILLS_TRIDENTS, SKILLS_MACES, SKILLS_SPEARS ->
                     String.valueOf(startingLevel);
                     String.valueOf(startingLevel);
             case OVERHAUL_LAST_LOGIN -> String.valueOf(-1L);
             case OVERHAUL_LAST_LOGIN -> String.valueOf(-1L);
             case COOLDOWN_BERSERK, COOLDOWN_GIGA_DRILL_BREAKER, COOLDOWN_TREE_FELLER,
             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_SERRATED_STRIKES, COOLDOWN_SKULL_SPLITTER, COOLDOWN_SUPER_BREAKER,
                  COOLDOWN_BLAST_MINING,
                  COOLDOWN_BLAST_MINING,
                  COOLDOWN_SUPER_SHOTGUN, COOLDOWN_TRIDENTS, COOLDOWN_ARCHERY, COOLDOWN_MACES,
                  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_MINING, EXP_WOODCUTTING, EXP_REPAIR, EXP_UNARMED, EXP_HERBALISM,
                  EXP_EXCAVATION, EXP_ARCHERY,
                  EXP_EXCAVATION, EXP_ARCHERY,
                  EXP_SWORDS, EXP_AXES, EXP_ACROBATICS, EXP_TAMING, EXP_FISHING, EXP_ALCHEMY,
                  EXP_SWORDS, EXP_AXES, EXP_ACROBATICS, EXP_TAMING, EXP_FISHING, EXP_ALCHEMY,
                  EXP_CROSSBOWS,
                  EXP_CROSSBOWS,
-                 EXP_TRIDENTS, EXP_MACES -> "0";
+                 EXP_TRIDENTS, EXP_MACES, EXP_SPEARS -> "0";
             case UUID_INDEX ->
             case UUID_INDEX ->
                     throw new IndexOutOfBoundsException(); //TODO: Add UUID recovery? Might not even be worth it.
                     throw new IndexOutOfBoundsException(); //TODO: Add UUID recovery? Might not even be worth it.
             default -> throw new IndexOutOfBoundsException();
             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 {
 public enum UpgradeType {
     ADD_FISHING,
     ADD_FISHING,
     ADD_BLAST_MINING_COOLDOWN,
     ADD_BLAST_MINING_COOLDOWN,
-    ADD_SQL_INDEXES,
     ADD_MOB_HEALTHBARS,
     ADD_MOB_HEALTHBARS,
     DROP_SQL_PARTY_NAMES,
     DROP_SQL_PARTY_NAMES,
     DROP_SPOUT,
     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.repair.RepairManager;
 import com.gmail.nossr50.skills.salvage.SalvageManager;
 import com.gmail.nossr50.skills.salvage.SalvageManager;
 import com.gmail.nossr50.skills.smelting.SmeltingManager;
 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.swords.SwordsManager;
 import com.gmail.nossr50.skills.taming.TamingManager;
 import com.gmail.nossr50.skills.taming.TamingManager;
 import com.gmail.nossr50.skills.tridents.TridentsManager;
 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.EnumMap;
 import java.util.Map;
 import java.util.Map;
 import java.util.UUID;
 import java.util.UUID;
+import java.util.logging.Level;
 import net.kyori.adventure.identity.Identified;
 import net.kyori.adventure.identity.Identified;
 import net.kyori.adventure.identity.Identity;
 import net.kyori.adventure.identity.Identity;
 import org.bukkit.Bukkit;
 import org.bukkit.Bukkit;
@@ -171,73 +173,50 @@ public class McMMOPlayer implements Identified {
             try {
             try {
                 initManager(primarySkillType);
                 initManager(primarySkillType);
             } catch (InvalidSkillException e) {
             } 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
     //TODO: Add test
     private void initManager(PrimarySkillType primarySkillType) throws InvalidSkillException {
     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);
         return (SmeltingManager) skillManagers.get(PrimarySkillType.SMELTING);
     }
     }
 
 
+    public SpearsManager getSpearsManager() {
+        return (SpearsManager) skillManagers.get(PrimarySkillType.SPEARS);
+    }
+
     public SwordsManager getSwordsManager() {
     public SwordsManager getSwordsManager() {
         return (SwordsManager) skillManagers.get(PrimarySkillType.SWORDS);
         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;
         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(playerName, uuid, startingLvl);
         this.loaded = isLoaded;
         this.loaded = isLoaded;
     }
     }

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

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

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

@@ -83,6 +83,11 @@ public enum SubSkillType {
     SMELTING_SECOND_SMELT,
     SMELTING_SECOND_SMELT,
     SMELTING_UNDERSTANDING_THE_ART(8),
     SMELTING_UNDERSTANDING_THE_ART(8),
 
 
+    /* Spears */
+    SPEARS_SPEARS_LIMIT_BREAK(10),
+    SPEARS_MOMENTUM(10),
+    SPEARS_SPEAR_MASTERY(8),
+
     /* Swords */
     /* Swords */
     SWORDS_COUNTER_ATTACK(1),
     SWORDS_COUNTER_ATTACK(1),
     SWORDS_RUPTURE(4),
     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",
             "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
      * 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 SUPER_BREAKER -> Permissions.superBreaker(player);
             case TREE_FELLER -> Permissions.treeFeller(player);
             case TREE_FELLER -> Permissions.treeFeller(player);
             // TODO: once implemented, return permissions for the following abilities
             // 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())) {
         if (WorldBlacklist.isWorldBlacklisted(event.getEntity().getWorld())) {
             return;
             return;
         }
         }
+
         // world guard main flag check
         // world guard main flag check
         if (WorldGuardUtils.isWorldGuardLoaded() && !WorldGuardManager.getInstance()
         if (WorldGuardUtils.isWorldGuardLoaded() && !WorldGuardManager.getInstance()
                 .hasMainFlag((Player) event.getEntity())) {
                 .hasMainFlag((Player) event.getEntity())) {
@@ -342,8 +343,8 @@ public class PlayerListener implements Listener {
         FishingManager fishingManager = UserManager.getPlayer(player).getFishingManager();
         FishingManager fishingManager = UserManager.getPlayer(player).getFishingManager();
 
 
         switch (event.getState()) {
         switch (event.getState()) {
+            // CAUGHT_FISH happens for any item caught (including junk and treasure)
             case CAUGHT_FISH:
             case CAUGHT_FISH:
-                //TODO Update to new API once available! Waiting for case CAUGHT_TREASURE
                 if (event.getCaught() != null) {
                 if (event.getCaught() != null) {
                     Item fishingCatch = (Item) event.getCaught();
                     Item fishingCatch = (Item) event.getCaught();
 
 
@@ -675,6 +676,10 @@ public class PlayerListener implements Listener {
      */
      */
     @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
     @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
     public void onPlayerInteractLowest(PlayerInteractEvent event) {
     public void onPlayerInteractLowest(PlayerInteractEvent event) {
+        if (event.getAction() == Action.PHYSICAL) {
+            return;
+        }
+
         /* WORLD BLACKLIST CHECK */
         /* WORLD BLACKLIST CHECK */
         if (WorldBlacklist.isWorldBlacklisted(event.getPlayer().getWorld())) {
         if (WorldBlacklist.isWorldBlacklisted(event.getPlayer().getWorld())) {
             return;
             return;
@@ -817,6 +822,10 @@ public class PlayerListener implements Listener {
      */
      */
     @EventHandler(priority = EventPriority.MONITOR)
     @EventHandler(priority = EventPriority.MONITOR)
     public void onPlayerInteractMonitor(PlayerInteractEvent event) {
     public void onPlayerInteractMonitor(PlayerInteractEvent event) {
+        if (event.getAction() == Action.PHYSICAL) {
+            return;
+        }
+
         /* WORLD BLACKLIST CHECK */
         /* WORLD BLACKLIST CHECK */
         if (WorldBlacklist.isWorldBlacklisted(event.getPlayer().getWorld())) {
         if (WorldBlacklist.isWorldBlacklisted(event.getPlayer().getWorld())) {
             return;
             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) {
     public static boolean hasItemInEitherHand(@NotNull Player player, Material material) {
         return player.getInventory().getItemInMainHand().getType() == material
         return player.getInventory().getItemInMainHand().getType() == material
                 || player.getInventory().getItemInOffHand().getType() == material;
                 || player.getInventory().getItemInOffHand().getType() == material;
@@ -276,6 +262,46 @@ public final class ItemUtils {
         return null;
         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.
      * 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 final String DEBUG_STR = "[D] ";
 
 
     public static void debug(@NotNull Logger logger, @NotNull String message) {
     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);
         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> tridents;
     private final @NotNull HashSet<String> bows;
     private final @NotNull HashSet<String> bows;
     private final @NotNull HashSet<String> crossbows;
     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> 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> ores;
     private final @NotNull HashSet<String> intendedToolPickAxe;
     private final @NotNull HashSet<String> intendedToolPickAxe;
@@ -95,15 +96,15 @@ public class MaterialMapStore {
         crossbows = new HashSet<>();
         crossbows = new HashSet<>();
         stringTools = new HashSet<>();
         stringTools = new HashSet<>();
         prismarineTools = new HashSet<>();
         prismarineTools = new HashSet<>();
-        tools = new HashSet<>();
-
         swords = new HashSet<>();
         swords = new HashSet<>();
         axes = new HashSet<>();
         axes = new HashSet<>();
         pickAxes = new HashSet<>();
         pickAxes = new HashSet<>();
         shovels = new HashSet<>();
         shovels = new HashSet<>();
         hoes = new HashSet<>();
         hoes = new HashSet<>();
         tridents = new HashSet<>();
         tridents = new HashSet<>();
+        spears = new HashSet<>();
         maces = new HashSet<>();
         maces = new HashSet<>();
+        tools = new HashSet<>();
 
 
         enchantables = new HashSet<>();
         enchantables = new HashSet<>();
 
 
@@ -459,6 +460,7 @@ public class MaterialMapStore {
         enchantables.addAll(bows);
         enchantables.addAll(bows);
         enchantables.addAll(crossbows);
         enchantables.addAll(crossbows);
         enchantables.addAll(maces);
         enchantables.addAll(maces);
+        enchantables.addAll(spears);
 
 
         enchantables.add("shears");
         enchantables.add("shears");
         enchantables.add("fishing_rod");
         enchantables.add("fishing_rod");
@@ -484,6 +486,7 @@ public class MaterialMapStore {
         fillShovels();
         fillShovels();
         fillTridents();
         fillTridents();
         fillMaces();
         fillMaces();
+        fillSpears();
         fillStringTools();
         fillStringTools();
         fillPrismarineTools();
         fillPrismarineTools();
         fillBows();
         fillBows();
@@ -502,6 +505,7 @@ public class MaterialMapStore {
         tools.addAll(bows);
         tools.addAll(bows);
         tools.addAll(crossbows);
         tools.addAll(crossbows);
         tools.addAll(maces);
         tools.addAll(maces);
+        tools.addAll(spears);
     }
     }
 
 
     private void fillBows() {
     private void fillBows() {
@@ -527,6 +531,16 @@ public class MaterialMapStore {
         maces.add("mace");
         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() {
     private void fillTridents() {
         tridents.add("trident");
         tridents.add("trident");
     }
     }
@@ -874,6 +888,14 @@ public class MaterialMapStore {
         return maces.contains(id);
         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) {
     public boolean isLeatherArmor(@NotNull Material material) {
         return isLeatherArmor(material.getKey().getKey());
         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.RepairCommand;
 import com.gmail.nossr50.commands.skills.SalvageCommand;
 import com.gmail.nossr50.commands.skills.SalvageCommand;
 import com.gmail.nossr50.commands.skills.SmeltingCommand;
 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.SwordsCommand;
 import com.gmail.nossr50.commands.skills.TamingCommand;
 import com.gmail.nossr50.commands.skills.TamingCommand;
 import com.gmail.nossr50.commands.skills.TridentsCommand;
 import com.gmail.nossr50.commands.skills.TridentsCommand;
@@ -101,6 +102,7 @@ public final class CommandRegistrationManager {
                 case REPAIR -> command.setExecutor(new RepairCommand());
                 case REPAIR -> command.setExecutor(new RepairCommand());
                 case SALVAGE -> command.setExecutor(new SalvageCommand());
                 case SALVAGE -> command.setExecutor(new SalvageCommand());
                 case SMELTING -> command.setExecutor(new SmeltingCommand());
                 case SMELTING -> command.setExecutor(new SmeltingCommand());
+                case SPEARS -> command.setExecutor(new SpearsCommand());
                 case SWORDS -> command.setExecutor(new SwordsCommand());
                 case SWORDS -> command.setExecutor(new SwordsCommand());
                 case TAMING -> command.setExecutor(new TamingCommand());
                 case TAMING -> command.setExecutor(new TamingCommand());
                 case TRIDENTS -> command.setExecutor(new TridentsCommand());
                 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.datatypes.experience.XPGainReason.PVP;
 import static com.gmail.nossr50.util.AttributeMapper.MAPPED_MOVEMENT_SPEED;
 import static com.gmail.nossr50.util.AttributeMapper.MAPPED_MOVEMENT_SPEED;
 import static com.gmail.nossr50.util.MobMetadataUtils.hasMobFlag;
 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 static com.gmail.nossr50.util.skills.ProjectileUtils.isCrossbowProjectile;
 
 
 import com.gmail.nossr50.config.experience.ExperienceConfig;
 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.archery.ArcheryManager;
 import com.gmail.nossr50.skills.axes.AxesManager;
 import com.gmail.nossr50.skills.axes.AxesManager;
 import com.gmail.nossr50.skills.maces.MacesManager;
 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.swords.SwordsManager;
 import com.gmail.nossr50.skills.taming.TamingManager;
 import com.gmail.nossr50.skills.taming.TamingManager;
 import com.gmail.nossr50.skills.tridents.TridentsManager;
 import com.gmail.nossr50.skills.tridents.TridentsManager;
@@ -331,6 +333,46 @@ public final class CombatUtils {
         printFinalDamageDebug(player, event, mmoPlayer);
         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,
     private static void processAxeCombat(@NotNull LivingEntity target, @NotNull Player player,
             @NotNull EntityDamageByEntityEvent event) {
             @NotNull EntityDamageByEntityEvent event) {
         if (event.getCause() == DamageCause.THORNS) {
         if (event.getCause() == DamageCause.THORNS) {
@@ -391,6 +433,11 @@ public final class CombatUtils {
 
 
         double boostedDamage = event.getDamage();
         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);
         final McMMOPlayer mmoPlayer = UserManager.getPlayer(player);
 
 
         //Make sure the profiles been loaded
         //Make sure the profiles been loaded
@@ -642,6 +689,15 @@ public final class CombatUtils {
                         .doesPlayerHaveSkillPermission(player, PrimarySkillType.MACES)) {
                         .doesPlayerHaveSkillPermission(player, PrimarySkillType.MACES)) {
                     processMacesCombat(target, player, event);
                     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) {
         } else if (entityType == EntityType.WOLF) {
             Wolf wolf = (Wolf) painSource;
             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;
 package com.gmail.nossr50.util.skills;
 
 
-import com.gmail.nossr50.api.exceptions.InvalidSkillException;
 import com.gmail.nossr50.config.experience.ExperienceConfig;
 import com.gmail.nossr50.config.experience.ExperienceConfig;
 import com.gmail.nossr50.datatypes.skills.PrimarySkillType;
 import com.gmail.nossr50.datatypes.skills.PrimarySkillType;
 import com.gmail.nossr50.datatypes.skills.SubSkillType;
 import com.gmail.nossr50.datatypes.skills.SubSkillType;
@@ -18,6 +17,7 @@ import java.util.Collections;
 import java.util.EnumMap;
 import java.util.EnumMap;
 import java.util.HashSet;
 import java.util.HashSet;
 import java.util.List;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.Set;
 import org.bukkit.entity.Entity;
 import org.bukkit.entity.Entity;
 import org.bukkit.entity.Player;
 import org.bukkit.entity.Player;
@@ -27,15 +27,16 @@ import org.jetbrains.annotations.VisibleForTesting;
 
 
 public class SkillTools {
 public class SkillTools {
     private final mcMMO pluginRef;
     private final mcMMO pluginRef;
+
     // TODO: Java has immutable types now, switch to those
     // 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
     // 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> LOCALIZED_SKILL_NAMES;
     public final @NotNull ImmutableList<String> FORMATTED_SUBSKILL_NAMES;
     public final @NotNull ImmutableList<String> FORMATTED_SUBSKILL_NAMES;
     public final @NotNull ImmutableSet<String> EXACT_SUBSKILL_NAMES;
     public final @NotNull ImmutableSet<String> EXACT_SUBSKILL_NAMES;
     public final @NotNull ImmutableList<PrimarySkillType> CHILD_SKILLS;
     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> COMBAT_SKILLS;
     public final @NotNull ImmutableList<PrimarySkillType> GATHERING_SKILLS;
     public final @NotNull ImmutableList<PrimarySkillType> GATHERING_SKILLS;
     public final @NotNull ImmutableList<PrimarySkillType> MISC_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<SuperAbilityType, PrimarySkillType> superAbilityParentRelationshipMap;
     private final @NotNull ImmutableMap<PrimarySkillType, Set<SubSkillType>> primarySkillChildrenMap;
     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, SuperAbilityType> mainActivatedAbilityChildMap;
     private final ImmutableMap<PrimarySkillType, ToolType> primarySkillToolMap;
     private final ImmutableMap<PrimarySkillType, ToolType> primarySkillToolMap;
 
 
     static {
     static {
+        // Build NON_CHILD_SKILLS once from the enum values
         ArrayList<PrimarySkillType> tempNonChildSkills = new ArrayList<>();
         ArrayList<PrimarySkillType> tempNonChildSkills = new ArrayList<>();
         for (PrimarySkillType primarySkillType : PrimarySkillType.values()) {
         for (PrimarySkillType primarySkillType : PrimarySkillType.values()) {
-            if (primarySkillType != PrimarySkillType.SALVAGE
-                    && primarySkillType != PrimarySkillType.SMELTING) {
+            if (!isChildSkill(primarySkillType)) {
                 tempNonChildSkills.add(primarySkillType);
                 tempNonChildSkills.add(primarySkillType);
             }
             }
         }
         }
-
         NON_CHILD_SKILLS = ImmutableList.copyOf(tempNonChildSkills);
         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;
         this.pluginRef = pluginRef;
 
 
         /*
         /*
          * Setup subskill -> parent relationship map
          * 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()) {
         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.AXES, ToolType.AXE);
         tempToolMap.put(PrimarySkillType.WOODCUTTING, ToolType.AXE);
         tempToolMap.put(PrimarySkillType.WOODCUTTING, ToolType.AXE);
@@ -120,56 +189,76 @@ public class SkillTools {
         tempToolMap.put(PrimarySkillType.HERBALISM, ToolType.HOE);
         tempToolMap.put(PrimarySkillType.HERBALISM, ToolType.HOE);
         tempToolMap.put(PrimarySkillType.MINING, ToolType.PICKAXE);
         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()) {
         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<>();
         List<PrimarySkillType> childSkills = new ArrayList<>();
-
         for (PrimarySkillType primarySkillType : PrimarySkillType.values()) {
         for (PrimarySkillType primarySkillType : PrimarySkillType.values()) {
             if (isChildSkill(primarySkillType)) {
             if (isChildSkill(primarySkillType)) {
                 childSkills.add(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.ARCHERY,
                     PrimarySkillType.AXES,
                     PrimarySkillType.AXES,
                     PrimarySkillType.CROSSBOWS,
                     PrimarySkillType.CROSSBOWS,
@@ -177,42 +266,23 @@ public class SkillTools {
                     PrimarySkillType.SWORDS,
                     PrimarySkillType.SWORDS,
                     PrimarySkillType.TAMING,
                     PrimarySkillType.TAMING,
                     PrimarySkillType.TRIDENTS,
                     PrimarySkillType.TRIDENTS,
-                    PrimarySkillType.UNARMED);
+                    PrimarySkillType.UNARMED
+            );
         } else {
         } else {
             // No Maces in this version
             // No Maces in this version
-            COMBAT_SKILLS = ImmutableList.of(
+            return ImmutableList.of(
                     PrimarySkillType.ARCHERY,
                     PrimarySkillType.ARCHERY,
                     PrimarySkillType.AXES,
                     PrimarySkillType.AXES,
                     PrimarySkillType.CROSSBOWS,
                     PrimarySkillType.CROSSBOWS,
                     PrimarySkillType.SWORDS,
                     PrimarySkillType.SWORDS,
                     PrimarySkillType.TAMING,
                     PrimarySkillType.TAMING,
                     PrimarySkillType.TRIDENTS,
                     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) {
         return switch (superAbilityType) {
             case BERSERK -> PrimarySkillType.UNARMED;
             case BERSERK -> PrimarySkillType.UNARMED;
             case GREEN_TERRA -> PrimarySkillType.HERBALISM;
             case GREEN_TERRA -> PrimarySkillType.HERBALISM;
@@ -225,11 +295,12 @@ public class SkillTools {
             case TRIDENTS_SUPER_ABILITY -> PrimarySkillType.TRIDENTS;
             case TRIDENTS_SUPER_ABILITY -> PrimarySkillType.TRIDENTS;
             case EXPLOSIVE_SHOT -> PrimarySkillType.ARCHERY;
             case EXPLOSIVE_SHOT -> PrimarySkillType.ARCHERY;
             case MACES_SUPER_ABILITY -> PrimarySkillType.MACES;
             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
      * @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
      * @param skillName target skill name
      * @return the matching PrimarySkillType if one is found, otherwise null
      * @return the matching PrimarySkillType if one is found, otherwise null
@@ -282,8 +356,9 @@ public class SkillTools {
     public PrimarySkillType matchSkill(String skillName) {
     public PrimarySkillType matchSkill(String skillName) {
         if (!pluginRef.getGeneralConfig().getLocale().equalsIgnoreCase("en_US")) {
         if (!pluginRef.getGeneralConfig().getLocale().equalsIgnoreCase("en_US")) {
             for (PrimarySkillType type : PrimarySkillType.values()) {
             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;
                     return type;
                 }
                 }
             }
             }
@@ -297,15 +372,15 @@ public class SkillTools {
 
 
         if (!skillName.equalsIgnoreCase("all")) {
         if (!skillName.equalsIgnoreCase("all")) {
             pluginRef.getLogger()
             pluginRef.getLogger()
-                    .warning("Invalid mcMMO skill (" + skillName + ")"); //TODO: Localize
+                    .warning("Invalid mcMMO skill (" + skillName + ")"); // TODO: Localize
         }
         }
 
 
         return null;
         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
      * @param subSkillType target subskill
      * @return the PrimarySkillType of this SubSkill, null if it doesn't exist
      * @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
      * @param superAbilityType target super ability
      * @return the PrimarySkillType of this SuperAbilityType, null if it doesn't exist
      * @return the PrimarySkillType of this SuperAbilityType, null if it doesn't exist
@@ -326,16 +401,15 @@ public class SkillTools {
     }
     }
 
 
     public SuperAbilityType getSuperAbility(PrimarySkillType primarySkillType) {
     public SuperAbilityType getSuperAbility(PrimarySkillType primarySkillType) {
-        if (mainActivatedAbilityChildMap.get(primarySkillType) == null) {
-            return null;
-        }
-
         return mainActivatedAbilityChildMap.get(primarySkillType);
         return mainActivatedAbilityChildMap.get(primarySkillType);
     }
     }
 
 
     public boolean isSuperAbilityUnlocked(PrimarySkillType primarySkillType, Player player) {
     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();
         SubSkillType subSkillType = superAbilityType.getSubSkillTypeDefinition();
         return RankUtils.hasUnlockedSubskill(player, subSkillType);
         return RankUtils.hasUnlockedSubskill(player, subSkillType);
     }
     }
@@ -368,7 +442,6 @@ public class SkillTools {
         return ExperienceConfig.getInstance().getFormulaSkillModifier(primarySkillType);
         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) {
     public static boolean isChildSkill(PrimarySkillType primarySkillType) {
         return switch (primarySkillType) {
         return switch (primarySkillType) {
             case SALVAGE, SMELTING -> true;
             case SALVAGE, SMELTING -> true;
@@ -392,8 +465,10 @@ public class SkillTools {
     }
     }
 
 
     public boolean canCombatSkillsTrigger(PrimarySkillType primarySkillType, Entity target) {
     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);
                 : getPVEEnabled(primarySkillType);
     }
     }
 
 
@@ -410,7 +485,7 @@ public class SkillTools {
     }
     }
 
 
     public int getLevelCap(@NotNull PrimarySkillType primarySkillType) {
     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(
     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(
             default -> throw new IllegalArgumentException(
                     "Skill " + childSkill + " is not a child skill");
                     "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());
         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,
     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) {
             PrimarySkillType parentSkill) {
         for (SubSkillType subSkillType : SubSkillType.values()) {
         for (SubSkillType subSkillType : SubSkillType.values()) {
             if (subSkillType.getParentSkill() == parentSkill) {
             if (subSkillType.getParentSkill() == parentSkill) {

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

@@ -647,4 +647,19 @@ Skills:
                 Rank_1: 10
                 Rank_1: 10
                 Rank_2: 15
                 Rank_2: 15
                 Rank_3: 20
                 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
             Tridents: false
             Crossbows: false
             Crossbows: false
             Maces: false
             Maces: false
+            Spears: false
     Vampirism:
     Vampirism:
         Leech_Percentage: 5.0
         Leech_Percentage: 5.0
         Level_Threshold: 0
         Level_Threshold: 0
@@ -249,6 +250,7 @@ Hardcore:
             Tridents: false
             Tridents: false
             Crossbows: false
             Crossbows: false
             Maces: false
             Maces: false
+            Spears: false
 
 
 #
 #
 #  Settings for SMP Mods
 #  Settings for SMP Mods
@@ -427,6 +429,10 @@ Skills:
         Enabled_For_PVP: true
         Enabled_For_PVP: true
         Enabled_For_PVE: true
         Enabled_For_PVE: true
         Level_Cap: 0
         Level_Cap: 0
+    Spears:
+        Enabled_For_PVP: true
+        Enabled_For_PVE: true
+        Level_Cap: 0
     Taming:
     Taming:
         Enabled_For_PVP: true
         Enabled_For_PVP: true
         Enabled_For_PVE: true
         Enabled_For_PVE: true

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

@@ -95,6 +95,10 @@ Experience_Bars:
         Enable: true
         Enable: true
         Color: BLUE
         Color: BLUE
         BarStyle: SEGMENTED_6
         BarStyle: SEGMENTED_6
+    Spears:
+        Enable: true
+        Color: BLUE
+        BarStyle: SEGMENTED_6
     Repair:
     Repair:
         Enable: true
         Enable: true
         Color: PURPLE
         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.
     # 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:
     Skill_Multiplier:
+        Spears: 1.0
         Maces: 1.0
         Maces: 1.0
         Crossbows: 1.0
         Crossbows: 1.0
         Tridents: 1.0
         Tridents: 1.0
@@ -218,6 +223,7 @@ Diminished_Returns:
         Crossbows: 20000
         Crossbows: 20000
         Tridents: 20000
         Tridents: 20000
         Maces: 20000
         Maces: 20000
+        Spears: 20000
 
 
     Time_Interval: 10
     Time_Interval: 10
 
 
@@ -582,6 +588,7 @@ Experience_Values:
     Taming:
     Taming:
         Animal_Taming:
         Animal_Taming:
             Camel: 1300
             Camel: 1300
+            Camel_Husk: 1300
             Sniffer: 1500
             Sniffer: 1500
             Snifflet: 900
             Snifflet: 900
             Llama: 1200
             Llama: 1200
@@ -600,12 +607,15 @@ Experience_Values:
             Goat: 250
             Goat: 250
             Axolotl: 600
             Axolotl: 600
             Frog: 900
             Frog: 900
+            Nautilus: 1700
+            Zombie_Nautilus: 1700
     Combat:
     Combat:
         Multiplier:
         Multiplier:
             Animals: 1.0
             Animals: 1.0
             Armadillo: 1.1
             Armadillo: 1.1
             Creeper: 4.0
             Creeper: 4.0
             Skeleton: 3.0
             Skeleton: 3.0
+            Parched: 2.5
             Spider: 2.0
             Spider: 2.0
             Giant: 4.0
             Giant: 4.0
             Zombie: 2.0
             Zombie: 2.0
@@ -683,6 +693,7 @@ Experience_Values:
             Sniffer: 1.1
             Sniffer: 1.1
             Snifflet: 1.1
             Snifflet: 1.1
             Camel: 1.2
             Camel: 1.2
+            Camel_Husk: 1.25
             Bogged: 2.0
             Bogged: 2.0
             Breeze: 4.0
             Breeze: 4.0
             Armor_Stand: 0.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.Swords=Swords
 JSON.Taming=Taming
 JSON.Taming=Taming
 JSON.Tridents=Tridents
 JSON.Tridents=Tridents
+JSON.Spears=Spears
 JSON.Maces=Maces
 JSON.Maces=Maces
 JSON.Unarmed=Unarmed
 JSON.Unarmed=Unarmed
 JSON.Woodcutting=Woodcutting
 JSON.Woodcutting=Woodcutting
@@ -98,6 +99,7 @@ Overhaul.Name.Smelting=Smelting
 Overhaul.Name.Swords=Swords
 Overhaul.Name.Swords=Swords
 Overhaul.Name.Taming=Taming
 Overhaul.Name.Taming=Taming
 Overhaul.Name.Tridents=Tridents
 Overhaul.Name.Tridents=Tridents
+Overhaul.Name.Spears=Spears
 Overhaul.Name.Maces=Maces
 Overhaul.Name.Maces=Maces
 Overhaul.Name.Unarmed=Unarmed
 Overhaul.Name.Unarmed=Unarmed
 Overhaul.Name.Woodcutting=Woodcutting
 Overhaul.Name.Woodcutting=Woodcutting
@@ -125,6 +127,7 @@ XPBar.Smelting=Smelting Lv.&6{0}
 XPBar.Swords=Swords Lv.&6{0}
 XPBar.Swords=Swords Lv.&6{0}
 XPBar.Taming=Taming Lv.&6{0}
 XPBar.Taming=Taming Lv.&6{0}
 XPBar.Tridents=Tridents Lv.&6{0}
 XPBar.Tridents=Tridents Lv.&6{0}
+XPBar.Spears=Spears Lv.&6{0}
 XPBar.Maces=Maces Lv.&6{0}
 XPBar.Maces=Maces Lv.&6{0}
 XPBar.Unarmed=Unarmed Lv.&6{0}
 XPBar.Unarmed=Unarmed Lv.&6{0}
 XPBar.Woodcutting=Woodcutting 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.SubSkill.Cripple.Stat.Extra=[[DARK_AQUA]]Cripple Duration: &e{0}s&a vs Players, &e{1}s&a vs Mobs.
 Maces.Listener=Maces:
 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
 Swords.Ability.Lower=&7You lower your sword.
 Swords.Ability.Lower=&7You lower your sword.
 Swords.Ability.Ready=&3You &6ready&3 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.Swords=Attacking Monsters
 Commands.XPGain.Taming=Animal Taming, or combat w/ your wolves
 Commands.XPGain.Taming=Animal Taming, or combat w/ your wolves
 Commands.XPGain.Tridents=Attacking Monsters
 Commands.XPGain.Tridents=Attacking Monsters
+Commands.XPGain.Spears=Attacking Monsters
 Commands.XPGain.Unarmed=Attacking Monsters
 Commands.XPGain.Unarmed=Attacking Monsters
 Commands.XPGain.Woodcutting=Chopping down trees
 Commands.XPGain.Woodcutting=Chopping down trees
 Commands.XPGain=&8XP GAIN: &f{0}
 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.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.
 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
 # 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.
 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
 # 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
 Inspect.Offline= &cYou do not have permission to inspect offline players!
 Inspect.Offline= &cYou do not have permission to inspect offline players!
 Inspect.OfflineStats=mcMMO Stats for Offline Player &e{0}
 Inspect.OfflineStats=mcMMO Stats for Offline Player &e{0}

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

@@ -1,20 +1,20 @@
 name: mcMMO
 name: mcMMO
 version: ${project.version}
 version: ${project.version}
 description: >
 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
 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
 website: https://www.mcmmo.org
 main: com.gmail.nossr50.mcMMO
 main: com.gmail.nossr50.mcMMO
-softdepend: [WorldGuard, CombatTag, HealthBar, PlaceholderAPI, ProtocolLib]
+softdepend: [ WorldGuard, CombatTag, HealthBar, PlaceholderAPI, ProtocolLib ]
 load: POSTWORLD
 load: POSTWORLD
 folia-supported: true
 folia-supported: true
 api-version: 1.13
 api-version: 1.13
@@ -26,14 +26,14 @@ commands:
     mmocompat:
     mmocompat:
         description: Information about the server and whether its considered fully compatible or running in compatibility mode
         description: Information about the server and whether its considered fully compatible or running in compatibility mode
     mmodebug:
     mmodebug:
-        aliases: [mcmmodebugmode]
+        aliases: [ mcmmodebugmode ]
         description: Toggles a debug mode which will print useful information to chat
         description: Toggles a debug mode which will print useful information to chat
     mmoinfo:
     mmoinfo:
-        aliases: [mcinfo]
+        aliases: [ mcinfo ]
         description: Info pages for mcMMO
         description: Info pages for mcMMO
         permission: mcmmo.commands.mmoinfo
         permission: mcmmo.commands.mmoinfo
     xprate:
     xprate:
-        aliases: [mcxprate]
+        aliases: [ mcxprate ]
         description: Modify the xp rate or start an event
         description: Modify the xp rate or start an event
         permission: mcmmo.commands.xprate
         permission: mcmmo.commands.xprate
     mcmmo:
     mcmmo:
@@ -59,7 +59,7 @@ commands:
         permission: mcmmo.commands.mcrefresh
         permission: mcmmo.commands.mcrefresh
     mccooldown:
     mccooldown:
         description: Show the cooldowns on all your mcMMO abilities
         description: Show the cooldowns on all your mcMMO abilities
-        aliases: [mccooldowns]
+        aliases: [ mccooldowns ]
         permission: mcmmo.commands.mccooldown
         permission: mcmmo.commands.mccooldown
     mcchatspy:
     mcchatspy:
         description: Toggle mcMMO Party Chat spying on/off
         description: Toggle mcMMO Party Chat spying on/off
@@ -68,7 +68,7 @@ commands:
         description: Toggle mcMMO god-mode on/off
         description: Toggle mcMMO god-mode on/off
         permission: mcmmo.commands.mcgod
         permission: mcmmo.commands.mcgod
     mcstats:
     mcstats:
-        aliases: [stats]
+        aliases: [ stats ]
         description: Shows your mcMMO stats and xp
         description: Shows your mcMMO stats and xp
         permission: mcmmo.commands.mcstats
         permission: mcmmo.commands.mcstats
     mcremove:
     mcremove:
@@ -84,7 +84,7 @@ commands:
         description: Create/join a party
         description: Create/join a party
         permission: mcmmo.commands.party
         permission: mcmmo.commands.party
     inspect:
     inspect:
-        aliases: [mcinspect, mmoinspect]
+        aliases: [ mcinspect, mmoinspect ]
         description: View detailed mcMMO info on another player
         description: View detailed mcMMO info on another player
         permission: mcmmo.commands.inspect
         permission: mcmmo.commands.inspect
     mmoshowdb:
     mmoshowdb:
@@ -94,7 +94,7 @@ commands:
         description: Convert between different database and formula types
         description: Convert between different database and formula types
         permission: mcmmo.commands.mcconvert
         permission: mcmmo.commands.mcconvert
     partychat:
     partychat:
-        aliases: [pc, p]
+        aliases: [ pc, p ]
         description: Toggle Party chat or send party chat messages
         description: Toggle Party chat or send party chat messages
         permission: mcmmo.chat.partychat
         permission: mcmmo.chat.partychat
     skillreset:
     skillreset:
@@ -145,6 +145,9 @@ commands:
     smelting:
     smelting:
         description: Detailed mcMMO skill info
         description: Detailed mcMMO skill info
         permission: mcmmo.commands.smelting
         permission: mcmmo.commands.smelting
+    spears:
+        description: Detailed mcMMO skill info
+        permission: mcmmo.commands.spears
     alchemy:
     alchemy:
         description: Detailed mcMMO skill info
         description: Detailed mcMMO skill info
         permission: mcmmo.commands.alchemy
         permission: mcmmo.commands.alchemy
@@ -157,24 +160,24 @@ commands:
     mmopower:
     mmopower:
         description: Shows skill mastery and power level info
         description: Shows skill mastery and power level info
         permission: mcmmo.commands.mmopower
         permission: mcmmo.commands.mmopower
-        aliases: [mmopowerlevel, powerlevel]
+        aliases: [ mmopowerlevel, powerlevel ]
     adminchat:
     adminchat:
-        aliases: [ac, a]
+        aliases: [ ac, a ]
         description: Toggle Admin chat or send admin chat messages
         description: Toggle Admin chat or send admin chat messages
         permission: mcmmo.chat.adminchat
         permission: mcmmo.chat.adminchat
     mcpurge:
     mcpurge:
         description: Purge users with 0 powerlevel and/or who haven't connected in several months from the server DB.
         description: Purge users with 0 powerlevel and/or who haven't connected in several months from the server DB.
         permission: mcmmo.commands.mcpurge
         permission: mcmmo.commands.mcpurge
     mcnotify:
     mcnotify:
-        aliases: [notify]
+        aliases: [ notify ]
         description: Toggle mcMMO abilities chat display notifications on/off
         description: Toggle mcMMO abilities chat display notifications on/off
         permission: mcmmo.commands.mcnotify
         permission: mcmmo.commands.mcnotify
     mcscoreboard:
     mcscoreboard:
-        aliases: [mcsb]
+        aliases: [ mcsb ]
         description: Manage your mcMMO Scoreboard
         description: Manage your mcMMO Scoreboard
         permission: mcmmo.commands.mcscoreboard
         permission: mcmmo.commands.mcscoreboard
     mcmmoreloadlocale:
     mcmmoreloadlocale:
-        aliases: [mcreloadlocale]
+        aliases: [ mcreloadlocale ]
         description: Reloads locale
         description: Reloads locale
         permission: mcmmo.commands.reloadlocale
         permission: mcmmo.commands.reloadlocale
 permissions:
 permissions:
@@ -237,6 +240,7 @@ permissions:
             mcmmo.ability.repair.all: true
             mcmmo.ability.repair.all: true
             mcmmo.ability.salvage.all: true
             mcmmo.ability.salvage.all: true
             mcmmo.ability.smelting.all: true
             mcmmo.ability.smelting.all: true
+            mcmmo.ability.spears.all: true
             mcmmo.ability.swords.all: true
             mcmmo.ability.swords.all: true
             mcmmo.ability.taming.all: true
             mcmmo.ability.taming.all: true
             mcmmo.ability.tridents.all: true
             mcmmo.ability.tridents.all: true
@@ -320,19 +324,19 @@ permissions:
     mcmmo.ability.axes.skullsplitter:
     mcmmo.ability.axes.skullsplitter:
         description: Allows access to the Skull Splitter ability
         description: Allows access to the Skull Splitter ability
     mcmmo.ability.crossbows.*:
     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: true
     mcmmo.ability.crossbows.all:
     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:
     mcmmo.ability.crossbows.crossbowslimitbreak:
-            description: Adds damage to crossbows
+        description: Adds damage to crossbows
     mcmmo.ability.crossbows.trickshot:
     mcmmo.ability.crossbows.trickshot:
-            description: Allows access to the Trick Shot ability
+        description: Allows access to the Trick Shot ability
     mcmmo.ability.crossbows.poweredshot:
     mcmmo.ability.crossbows.poweredshot:
         description: Allows access to the Powered Shot ability
         description: Allows access to the Powered Shot ability
     mcmmo.ability.excavation.*:
     mcmmo.ability.excavation.*:
@@ -646,6 +650,23 @@ permissions:
         description: Allows access to the Second Smelt ability
         description: Allows access to the Second Smelt ability
     mcmmo.ability.smelting.vanillaxpboost:
     mcmmo.ability.smelting.vanillaxpboost:
         description: Allows vanilla XP boost from Smelting
         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.*:
     mcmmo.ability.swords.*:
         default: false
         default: false
         description: Allows access to all Swords abilities
         description: Allows access to all Swords abilities
@@ -885,6 +906,7 @@ permissions:
             mcmmo.commands.repair: true
             mcmmo.commands.repair: true
             mcmmo.commands.salvage: true
             mcmmo.commands.salvage: true
             mcmmo.commands.smelting: true
             mcmmo.commands.smelting: true
+            mcmmo.commands.spears: true
             mcmmo.commands.swords: true
             mcmmo.commands.swords: true
             mcmmo.commands.taming: true
             mcmmo.commands.taming: true
             mcmmo.commands.unarmed: true
             mcmmo.commands.unarmed: true
@@ -898,7 +920,7 @@ permissions:
             mcmmo.commands.addxp: true
             mcmmo.commands.addxp: true
             mcmmo.commands.addxp.others: true
             mcmmo.commands.addxp.others: true
             mcmmo.commands.defaults: true
             mcmmo.commands.defaults: true
-#            mcmmo.commands.hardcore.all: true
+            #            mcmmo.commands.hardcore.all: true
             mcmmo.commands.inspect.far: true
             mcmmo.commands.inspect.far: true
             mcmmo.commands.inspect.hidden: true
             mcmmo.commands.inspect.hidden: true
             mcmmo.commands.mcability.others: true
             mcmmo.commands.mcability.others: true
@@ -918,7 +940,7 @@ permissions:
             mcmmo.commands.ptp.world.all: true
             mcmmo.commands.ptp.world.all: true
             mcmmo.commands.reloadlocale: true
             mcmmo.commands.reloadlocale: true
             mcmmo.commands.skillreset.all: true
             mcmmo.commands.skillreset.all: true
-#            mcmmo.commands.vampirism.all: true
+            #            mcmmo.commands.vampirism.all: true
             mcmmo.commands.xprate.all: true
             mcmmo.commands.xprate.all: true
     mcmmo.commands.acrobatics:
     mcmmo.commands.acrobatics:
         description: Allows access to the acrobatics command
         description: Allows access to the acrobatics command
@@ -1058,6 +1080,7 @@ permissions:
             mcmmo.commands.mctop.repair: true
             mcmmo.commands.mctop.repair: true
             mcmmo.commands.mctop.salvage: true
             mcmmo.commands.mctop.salvage: true
             mcmmo.commands.mctop.smelting: true
             mcmmo.commands.mctop.smelting: true
+            mcmmo.commands.mctop.spears: true
             mcmmo.commands.mctop.swords: true
             mcmmo.commands.mctop.swords: true
             mcmmo.commands.mctop.taming: true
             mcmmo.commands.mctop.taming: true
             mcmmo.commands.mctop.tridents: true
             mcmmo.commands.mctop.tridents: true
@@ -1091,6 +1114,8 @@ permissions:
         description: Allows access to the mctop command for salvage
         description: Allows access to the mctop command for salvage
     mcmmo.commands.mctop.smelting:
     mcmmo.commands.mctop.smelting:
         description: Allows access to the mctop command for 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:
     mcmmo.commands.mctop.swords:
         description: Allows access to the mctop command for swords
         description: Allows access to the mctop command for swords
     mcmmo.commands.mctop.taming:
     mcmmo.commands.mctop.taming:
@@ -1239,6 +1264,7 @@ permissions:
             mcmmo.commands.skillreset.repair: true
             mcmmo.commands.skillreset.repair: true
             mcmmo.commands.skillreset.salvage: true
             mcmmo.commands.skillreset.salvage: true
             mcmmo.commands.skillreset.smelting: true
             mcmmo.commands.skillreset.smelting: true
+            mcmmo.commands.skillreset.spears: true
             mcmmo.commands.skillreset.swords: true
             mcmmo.commands.skillreset.swords: true
             mcmmo.commands.skillreset.taming: true
             mcmmo.commands.skillreset.taming: true
             mcmmo.commands.skillreset.unarmed: true
             mcmmo.commands.skillreset.unarmed: true
@@ -1268,6 +1294,8 @@ permissions:
         description: Allows access to the skillreset command for crossbows
         description: Allows access to the skillreset command for crossbows
     mcmmo.commands.skillreset.tridents:
     mcmmo.commands.skillreset.tridents:
         description: Allows access to the skillreset command for 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:
     mcmmo.commands.skillreset.maces:
         description: Allows access to the skillreset command for maces
         description: Allows access to the skillreset command for maces
     mcmmo.commands.skillreset.others.*:
     mcmmo.commands.skillreset.others.*:
@@ -1290,6 +1318,7 @@ permissions:
             mcmmo.commands.skillreset.others.repair: true
             mcmmo.commands.skillreset.others.repair: true
             mcmmo.commands.skillreset.others.salvage: true
             mcmmo.commands.skillreset.others.salvage: true
             mcmmo.commands.skillreset.others.smelting: true
             mcmmo.commands.skillreset.others.smelting: true
+            mcmmo.commands.skillreset.others.spears: true
             mcmmo.commands.skillreset.others.swords: true
             mcmmo.commands.skillreset.others.swords: true
             mcmmo.commands.skillreset.others.taming: true
             mcmmo.commands.skillreset.others.taming: true
             mcmmo.commands.skillreset.others.unarmed: true
             mcmmo.commands.skillreset.others.unarmed: true
@@ -1321,6 +1350,8 @@ permissions:
         description: Allows access to the skillreset command for salvage for other players
         description: Allows access to the skillreset command for salvage for other players
     mcmmo.commands.skillreset.others.smelting:
     mcmmo.commands.skillreset.others.smelting:
         description: Allows access to the skillreset command for smelting for other players
         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:
     mcmmo.commands.skillreset.others.swords:
         description: Allows access to the skillreset command for swords for other players
         description: Allows access to the skillreset command for swords for other players
     mcmmo.commands.skillreset.others.taming:
     mcmmo.commands.skillreset.others.taming:
@@ -1406,7 +1437,7 @@ permissions:
         default: false
         default: false
         description: implies access to all mcmmo perks
         description: implies access to all mcmmo perks
         children:
         children:
-            mcmmo.perks.all: true 
+            mcmmo.perks.all: true
     mcmmo.perks.all:
     mcmmo.perks.all:
         default: false
         default: false
         description: implies access to all mcmmo perks
         description: implies access to all mcmmo perks
@@ -1497,6 +1528,7 @@ permissions:
             mcmmo.perks.lucky.repair: true
             mcmmo.perks.lucky.repair: true
             mcmmo.perks.lucky.salvage: true
             mcmmo.perks.lucky.salvage: true
             mcmmo.perks.lucky.smelting: true
             mcmmo.perks.lucky.smelting: true
+            mcmmo.perks.lucky.spears: true
             mcmmo.perks.lucky.swords: true
             mcmmo.perks.lucky.swords: true
             mcmmo.perks.lucky.taming: true
             mcmmo.perks.lucky.taming: true
             mcmmo.perks.lucky.unarmed: true
             mcmmo.perks.lucky.unarmed: true
@@ -1539,6 +1571,9 @@ permissions:
     mcmmo.perks.lucky.salvage:
     mcmmo.perks.lucky.salvage:
         default: false
         default: false
         description: Gives Salvage abilities & skills a 33.3% better chance to activate.
         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:
     mcmmo.perks.lucky.smelting:
         default: false
         default: false
         description: Gives Smelting abilities & skills a 33.3% better chance to activate.
         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.mining: true
             mcmmo.perks.xp.150percentboost.repair: true
             mcmmo.perks.xp.150percentboost.repair: true
             mcmmo.perks.xp.150percentboost.smelting: true
             mcmmo.perks.xp.150percentboost.smelting: true
+            mcmmo.perks.xp.150percentboost.spears: true
             mcmmo.perks.xp.150percentboost.swords: true
             mcmmo.perks.xp.150percentboost.swords: true
             mcmmo.perks.xp.150percentboost.taming: true
             mcmmo.perks.xp.150percentboost.taming: true
             mcmmo.perks.xp.150percentboost.tridents: true
             mcmmo.perks.xp.150percentboost.tridents: true
@@ -1641,6 +1677,9 @@ permissions:
     mcmmo.perks.xp.150percentboost.smelting:
     mcmmo.perks.xp.150percentboost.smelting:
         default: false
         default: false
         description: Multiplies incoming Smelting XP by 2.5
         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:
     mcmmo.perks.xp.150percentboost.swords:
         default: false
         default: false
         description: Multiplies incoming Swords XP by 2.5
         description: Multiplies incoming Swords XP by 2.5
@@ -1682,6 +1721,7 @@ permissions:
             mcmmo.perks.xp.50percentboost.mining: true
             mcmmo.perks.xp.50percentboost.mining: true
             mcmmo.perks.xp.50percentboost.repair: true
             mcmmo.perks.xp.50percentboost.repair: true
             mcmmo.perks.xp.50percentboost.smelting: true
             mcmmo.perks.xp.50percentboost.smelting: true
+            mcmmo.perks.xp.50percentboost.spears: true
             mcmmo.perks.xp.50percentboost.swords: true
             mcmmo.perks.xp.50percentboost.swords: true
             mcmmo.perks.xp.50percentboost.taming: true
             mcmmo.perks.xp.50percentboost.taming: true
             mcmmo.perks.xp.50percentboost.tridents: true
             mcmmo.perks.xp.50percentboost.tridents: true
@@ -1720,6 +1760,9 @@ permissions:
     mcmmo.perks.xp.50percentboost.repair:
     mcmmo.perks.xp.50percentboost.repair:
         default: false
         default: false
         description: Multiplies incoming Repair XP by 1.5
         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:
     mcmmo.perks.xp.50percentboost.smelting:
         default: false
         default: false
         description: Multiplies incoming Smelting XP by 1.5
         description: Multiplies incoming Smelting XP by 1.5
@@ -1739,87 +1782,91 @@ permissions:
         default: false
         default: false
         description: Multiplies incoming Woodcutting XP by 1.5
         description: Multiplies incoming Woodcutting XP by 1.5
     mcmmo.perks.xp.25percentboost.*:
     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
         default: false
         description: Multiplies incoming XP by 1.25
         description: Multiplies incoming XP by 1.25
         children:
         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.*:
     mcmmo.perks.xp.10percentboost.*:
         default: false
         default: false
         description: Multiplies incoming XP by 1.1
         description: Multiplies incoming XP by 1.1
@@ -1846,6 +1893,7 @@ permissions:
             mcmmo.perks.xp.10percentboost.mining: true
             mcmmo.perks.xp.10percentboost.mining: true
             mcmmo.perks.xp.10percentboost.repair: true
             mcmmo.perks.xp.10percentboost.repair: true
             mcmmo.perks.xp.10percentboost.smelting: true
             mcmmo.perks.xp.10percentboost.smelting: true
+            mcmmo.perks.xp.10percentboost.spears: true
             mcmmo.perks.xp.10percentboost.swords: true
             mcmmo.perks.xp.10percentboost.swords: true
             mcmmo.perks.xp.10percentboost.taming: true
             mcmmo.perks.xp.10percentboost.taming: true
             mcmmo.perks.xp.10percentboost.tridents: true
             mcmmo.perks.xp.10percentboost.tridents: true
@@ -1884,6 +1932,9 @@ permissions:
     mcmmo.perks.xp.10percentboost.repair:
     mcmmo.perks.xp.10percentboost.repair:
         default: false
         default: false
         description: Multiplies incoming Repair XP by 1.1
         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:
     mcmmo.perks.xp.10percentboost.smelting:
         default: false
         default: false
         description: Multiplies incoming Smelting XP by 1.1
         description: Multiplies incoming Smelting XP by 1.1
@@ -1928,6 +1979,7 @@ permissions:
             mcmmo.perks.xp.customboost.mining: true
             mcmmo.perks.xp.customboost.mining: true
             mcmmo.perks.xp.customboost.repair: true
             mcmmo.perks.xp.customboost.repair: true
             mcmmo.perks.xp.customboost.smelting: true
             mcmmo.perks.xp.customboost.smelting: true
+            mcmmo.perks.xp.customboost.spears: true
             mcmmo.perks.xp.customboost.swords: true
             mcmmo.perks.xp.customboost.swords: true
             mcmmo.perks.xp.customboost.taming: true
             mcmmo.perks.xp.customboost.taming: true
             mcmmo.perks.xp.customboost.tridents: true
             mcmmo.perks.xp.customboost.tridents: true
@@ -1966,6 +2018,9 @@ permissions:
     mcmmo.perks.xp.customboost.repair:
     mcmmo.perks.xp.customboost.repair:
         default: false
         default: false
         description: Multiplies incoming Repair XP by the boost amount defined in the experience config
         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:
     mcmmo.perks.xp.customboost.smelting:
         default: false
         default: false
         description: Multiplies incoming Smelting XP by the boost amount defined in the experience config
         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.mining: true
             mcmmo.perks.xp.double.repair: true
             mcmmo.perks.xp.double.repair: true
             mcmmo.perks.xp.double.smelting: true
             mcmmo.perks.xp.double.smelting: true
+            mcmmo.perks.xp.double.spears: true
             mcmmo.perks.xp.double.swords: true
             mcmmo.perks.xp.double.swords: true
             mcmmo.perks.xp.double.taming: true
             mcmmo.perks.xp.double.taming: true
             mcmmo.perks.xp.double.tridents: true
             mcmmo.perks.xp.double.tridents: true
@@ -2048,6 +2104,9 @@ permissions:
     mcmmo.perks.xp.double.repair:
     mcmmo.perks.xp.double.repair:
         default: false
         default: false
         description: Doubles incoming Repair XP
         description: Doubles incoming Repair XP
+    mcmmo.perks.xp.double.spears:
+        default: false
+        description: Doubles incoming Smelting XP
     mcmmo.perks.xp.double.smelting:
     mcmmo.perks.xp.double.smelting:
         default: false
         default: false
         description: Doubles incoming Smelting XP
         description: Doubles incoming Smelting XP
@@ -2092,6 +2151,7 @@ permissions:
             mcmmo.perks.xp.quadruple.mining: true
             mcmmo.perks.xp.quadruple.mining: true
             mcmmo.perks.xp.quadruple.repair: true
             mcmmo.perks.xp.quadruple.repair: true
             mcmmo.perks.xp.quadruple.smelting: true
             mcmmo.perks.xp.quadruple.smelting: true
+            mcmmo.perks.xp.quadruple.spears: true
             mcmmo.perks.xp.quadruple.swords: true
             mcmmo.perks.xp.quadruple.swords: true
             mcmmo.perks.xp.quadruple.taming: true
             mcmmo.perks.xp.quadruple.taming: true
             mcmmo.perks.xp.quadruple.tridents: true
             mcmmo.perks.xp.quadruple.tridents: true
@@ -2133,6 +2193,9 @@ permissions:
     mcmmo.perks.xp.quadruple.smelting:
     mcmmo.perks.xp.quadruple.smelting:
         default: false
         default: false
         description: Quadruples incoming Smelting XP
         description: Quadruples incoming Smelting XP
+    mcmmo.perks.xp.quadruple.spears:
+        default: false
+        description: Quadruples incoming Spears XP
     mcmmo.perks.xp.quadruple.swords:
     mcmmo.perks.xp.quadruple.swords:
         default: false
         default: false
         description: Quadruples incoming Swords XP
         description: Quadruples incoming Swords XP
@@ -2174,6 +2237,7 @@ permissions:
             mcmmo.perks.xp.triple.maces: true
             mcmmo.perks.xp.triple.maces: true
             mcmmo.perks.xp.triple.repair: true
             mcmmo.perks.xp.triple.repair: true
             mcmmo.perks.xp.triple.smelting: true
             mcmmo.perks.xp.triple.smelting: true
+            mcmmo.perks.xp.triple.spears: true
             mcmmo.perks.xp.triple.swords: true
             mcmmo.perks.xp.triple.swords: true
             mcmmo.perks.xp.triple.taming: true
             mcmmo.perks.xp.triple.taming: true
             mcmmo.perks.xp.triple.tridents: true
             mcmmo.perks.xp.triple.tridents: true
@@ -2215,6 +2279,9 @@ permissions:
     mcmmo.perks.xp.triple.smelting:
     mcmmo.perks.xp.triple.smelting:
         default: false
         default: false
         description: Triples incoming Smelting XP
         description: Triples incoming Smelting XP
+    mcmmo.perks.xp.triple.spears:
+        default: false
+        description: Triples incoming Spears XP
     mcmmo.perks.xp.triple.swords:
     mcmmo.perks.xp.triple.swords:
         default: false
         default: false
         description: Triples incoming Swords XP
         description: Triples incoming Swords XP
@@ -2257,6 +2324,7 @@ permissions:
             mcmmo.skills.salvage: true
             mcmmo.skills.salvage: true
             mcmmo.skills.swords: true
             mcmmo.skills.swords: true
             mcmmo.skills.smelting: true
             mcmmo.skills.smelting: true
+            mcmmo.skills.spears: true
             mcmmo.skills.taming: true
             mcmmo.skills.taming: true
             mcmmo.skills.unarmed: true
             mcmmo.skills.unarmed: true
             mcmmo.skills.woodcutting: true
             mcmmo.skills.woodcutting: true
@@ -2322,6 +2390,11 @@ permissions:
         children:
         children:
             mcmmo.ability.smelting.all: true
             mcmmo.ability.smelting.all: true
             mcmmo.commands.smelting: 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:
     mcmmo.skills.swords:
         description: Allows access to the Swords skill
         description: Allows access to the Swords skill
         children:
         children:

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

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

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

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

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

@@ -404,6 +404,72 @@ Smelting:
             Rank_6: 750
             Rank_6: 750
             Rank_7: 850
             Rank_7: 850
             Rank_8: 1000
             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:
 Salvage:
     ScrapCollector:
     ScrapCollector:
         Standard:
         Standard:

文件差异内容过多而无法显示
+ 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:

部分文件因为文件数量过多而无法显示