diff --git a/Changelog.txt b/Changelog.txt index 75c35de7d..905cccf38 100644 --- a/Changelog.txt +++ b/Changelog.txt @@ -1,3 +1,33 @@ +Version 2.2.046 + Added Spears combat skill + Added Spears to repair.vanilla.yml and salvage.vanilla.yml (see notes) + Added various permissions related to Spears + Added /spears skill command + Added Nautilus to taming XP in experience.yml + Added Camel_Husk to taming XP in experience.yml + Added Camel_Husk to combat XP in experience.yml + Added Parched to combat XP in experience.yml + Fixed bug where converting from SQL to FlatFile would not copy data for tridents, crossbows, maces, or spears + (Codebase) Added docker-based unit tests for SQL databases (see notes) + (Codebase) Large refactor to both SQLDatabaseManager and FlatFileDatabaseManager + (Codebase) Database related errors are now more descriptive and have had their logging improved + + NOTES: + This update had a lot of changes behind the scenes, please report any bugs you find to our GitHub issues page! + You will need to manually update repair.vanilla.yml and salvage.vanilla.yml to get support for Spears, or... + If you want to update salvage/repair configs the easy way, you simply can delete these config files to have mcMMO regenerate them with the new entries. + If you don't want to delete them, you can find the default values for these config files in the defaults folder at plugins\mcMMO\defaults after running this mcMMO update at least once. + You can use this default file to copy paste if you please. + Docker is ONLY required for developers compiling mcMMO from source code and ONLY for running SQL-related unit tests. + mcMMO itself does NOT require Docker to run, and servers using prebuilt releases are completely unaffected. + New SQL database unit tests use Testcontainers to spin up temporary MySQL/MariaDB instances for testing purposes. + These containers are created at test time and are never used at runtime. + If you compile mcMMO locally and do not have Docker installed, SQL-related unit tests may fail. + In this case, you can safely compile with -DskipTests to skip unit tests entirely. + Skipping tests has no impact on mcMMO functionality when running on a server. + Known Issues: + I ran into an issue where having a spear in the offhand while the main hand is empty causes attacks to be incorrectly classified as unarmed. This allows unarmed abilities to apply to spear damage. As a temporary measure, I’ve disabled unarmed skills from applying to combat when a spear is equipped in the offhand while I investigate a more robust solution. + Version 2.2.045 Green Thumb now replants some crops it was failing to replant before (see notes) Green Thumb now replants harvested plants faster diff --git a/pom.xml b/pom.xml index e1ff83c24..07991cfd1 100644 --- a/pom.xml +++ b/pom.xml @@ -1,8 +1,10 @@ - + 4.0.0 com.gmail.nossr50.mcMMO mcMMO - 2.2.046-SNAPSHOT + 2.2.046 mcMMO https://github.com/mcMMO-Dev/mcMMO @@ -13,8 +15,8 @@ - - 1.21.10-R0.1-SNAPSHOT + + 1.21.11-R0.1-SNAPSHOT 4.23.0 4.4.1-SNAPSHOT 1.1.0 @@ -182,11 +184,13 @@ co.aikar.commands - com.gmail.nossr50.mcmmo.acf + com.gmail.nossr50.mcmmo.acf + co.aikar.locales - com.gmail.nossr50.mcmmo.locales + com.gmail.nossr50.mcmmo.locales + org.apache.commons.logging @@ -194,7 +198,8 @@ org.apache.juli - com.gmail.nossr50.mcmmo.database.tomcat.juli + com.gmail.nossr50.mcmmo.database.tomcat.juli + org.apache.tomcat @@ -385,11 +390,11 @@ 3.0.2 compile - - - - - + + + + + org.spigotmc spigot-api @@ -426,10 +431,76 @@ + org.junit.jupiter junit-jupiter - 5.11.0-M2 + 5.11.0 + test + + + + org.testcontainers + testcontainers + 2.0.2 + test + + + + org.testcontainers + testcontainers-junit-jupiter + 2.0.2 + test + + + + + org.apache.logging.log4j + log4j-core + 2.25.2 + test + + + + + org.apache.logging.log4j + log4j-api + 2.25.2 + + + + org.apache.logging.log4j + log4j-slf4j-impl + 2.25.2 + test + + + + org.testcontainers + testcontainers-mysql + test + + + + org.testcontainers + testcontainers-mariadb + test + + + + + com.mysql + mysql-connector-j + 9.5.0 + test + + + + + + org.mariadb.jdbc + mariadb-java-client + 3.5.6 test @@ -447,7 +518,7 @@ org.apache.tomcat tomcat-jdbc - 10.1.24 + 11.0.14 compile @@ -458,7 +529,8 @@ com.google.guava guava - 33.2.0-jre + 33.2.0-jre + compile @@ -468,4 +540,15 @@ compile + + + + org.testcontainers + testcontainers-bom + 2.0.2 + pom + import + + + diff --git a/src/main/java/com/gmail/nossr50/commands/skills/AcrobaticsCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/AcrobaticsCommand.java index 3ce1db82e..71abd05ac 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/AcrobaticsCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/AcrobaticsCommand.java @@ -73,7 +73,7 @@ public class AcrobaticsCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.ACROBATICS); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/AlchemyCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/AlchemyCommand.java index cd5570ba8..728ac0d46 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/AlchemyCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/AlchemyCommand.java @@ -92,7 +92,7 @@ public class AlchemyCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.ALCHEMY); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/ArcheryCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/ArcheryCommand.java index 77eb6706e..48f911a4d 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/ArcheryCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/ArcheryCommand.java @@ -93,7 +93,7 @@ public class ArcheryCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.ARCHERY); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/AxesCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/AxesCommand.java index 620fd6e17..0634b2641 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/AxesCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/AxesCommand.java @@ -119,7 +119,7 @@ public class AxesCommand extends SkillCommand { protected List getTextComponents(Player player) { final List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.AXES); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/CrossbowsCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/CrossbowsCommand.java index fe2a87ec7..59fb2b9c8 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/CrossbowsCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/CrossbowsCommand.java @@ -68,7 +68,7 @@ public class CrossbowsCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.CROSSBOWS); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/ExcavationCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/ExcavationCommand.java index d5068bd34..3382449ee 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/ExcavationCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/ExcavationCommand.java @@ -71,7 +71,7 @@ public class ExcavationCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.EXCAVATION); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/FishingCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/FishingCommand.java index d96066c39..9e6da23a1 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/FishingCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/FishingCommand.java @@ -185,7 +185,7 @@ public class FishingCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.FISHING); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/HerbalismCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/HerbalismCommand.java index 4a95874af..714c3080b 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/HerbalismCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/HerbalismCommand.java @@ -187,7 +187,7 @@ public class HerbalismCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.HERBALISM); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/MacesCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/MacesCommand.java index e2c6e3108..a24ff8aef 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/MacesCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/MacesCommand.java @@ -23,7 +23,8 @@ public class MacesCommand extends SkillCommand { super(PrimarySkillType.MACES); } - String crippleChanceToApply, crippleChanceToApplyLucky, crippleLengthAgainstPlayers, crippleLengthAgainstMobs; + String crippleChanceToApply, crippleChanceToApplyLucky, crippleLengthAgainstPlayers, + crippleLengthAgainstMobs; @Override protected void dataCalculations(Player player, float skillValue) { @@ -33,7 +34,6 @@ public class MacesCommand extends SkillCommand { MacesManager.getCrippleTickDuration(true) / 20.0D); crippleLengthAgainstMobs = String.valueOf( MacesManager.getCrippleTickDuration(false) / 20.0D); - crippleChanceToApply = mcMMO.p.getAdvancedConfig().getCrippleChanceToApplyOnHit(crippleRank) + "%"; crippleChanceToApplyLucky = String.valueOf( @@ -77,7 +77,7 @@ public class MacesCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.MACES); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/MiningCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/MiningCommand.java index 05af220ea..d2d129b7c 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/MiningCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/MiningCommand.java @@ -144,7 +144,7 @@ public class MiningCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.MINING); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/RepairCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/RepairCommand.java index e56a5e879..208c5f53b 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/RepairCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/RepairCommand.java @@ -134,7 +134,7 @@ public class RepairCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.REPAIR); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/SalvageCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/SalvageCommand.java index 29a8cd6c0..4a6fe7b1c 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/SalvageCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/SalvageCommand.java @@ -72,7 +72,7 @@ public class SalvageCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.SALVAGE); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/SmeltingCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/SmeltingCommand.java index 5b2eec7a1..5e92c4441 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/SmeltingCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/SmeltingCommand.java @@ -97,7 +97,7 @@ public class SmeltingCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.SMELTING); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/SpearsCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/SpearsCommand.java new file mode 100644 index 000000000..81c340313 --- /dev/null +++ b/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 statsDisplay(Player player, float skillValue, boolean hasEndurance, + boolean isLucky) { + final SpearsManager spearsManager = UserManager.getPlayer(player).getSpearsManager(); + final double spearMasteryBonusDmg = spearsManager.getSpearMasteryBonusDamage(); + + List 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 getTextComponents(Player player) { + List textComponents = new ArrayList<>(); + + appendSubSkillTextComponents(player, textComponents, PrimarySkillType.SPEARS); + + return textComponents; + } +} diff --git a/src/main/java/com/gmail/nossr50/commands/skills/SwordsCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/SwordsCommand.java index 54359c9df..966dcff1a 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/SwordsCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/SwordsCommand.java @@ -22,8 +22,8 @@ public class SwordsCommand extends SkillCommand { private String serratedStrikesLengthEndurance; private String rupturePureTickDamageAgainstPlayers, rupturePureTickDamageAgainstMobs, - ruptureExplosionDamageAgainstPlayers, ruptureExplosionDamageAgainstMobs, - ruptureLengthSecondsAgainstPlayers, ruptureLengthSecondsAgainstMobs, ruptureChanceToApply, ruptureChanceToApplyLucky; + ruptureLengthSecondsAgainstPlayers, ruptureLengthSecondsAgainstMobs, + ruptureChanceToApply, ruptureChanceToApplyLucky; private boolean canCounter; private boolean canSerratedStrike; @@ -56,11 +56,6 @@ public class SwordsCommand extends SkillCommand { rupturePureTickDamageAgainstMobs = String.valueOf( mcMMO.p.getAdvancedConfig().getRuptureTickDamage(false, ruptureRank)); - ruptureExplosionDamageAgainstPlayers = String.valueOf( - mcMMO.p.getAdvancedConfig().getRuptureExplosionDamage(true, ruptureRank)); - ruptureExplosionDamageAgainstMobs = String.valueOf( - mcMMO.p.getAdvancedConfig().getRuptureExplosionDamage(false, ruptureRank)); - ruptureChanceToApply = mcMMO.p.getAdvancedConfig().getRuptureChanceToApplyOnHit(ruptureRank) + "%"; ruptureChanceToApplyLucky = String.valueOf( @@ -105,7 +100,6 @@ public class SwordsCommand extends SkillCommand { messages.add(LocaleLoader.getString("Swords.SubSkill.Rupture.Stat.TickDamage", rupturePureTickDamageAgainstPlayers, rupturePureTickDamageAgainstMobs)); -// messages.add(LocaleLoader.getString("Swords.SubSkill.Rupture.Stat.ExplosionDamage", ruptureExplosionDamageAgainstPlayers, ruptureExplosionDamageAgainstMobs)); messages.add(LocaleLoader.getString("Swords.Combat.Rupture.Note.Update.One")); } @@ -134,7 +128,7 @@ public class SwordsCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.SWORDS); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/TamingCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/TamingCommand.java index a7db4d82c..cf68d2c0a 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/TamingCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/TamingCommand.java @@ -115,7 +115,7 @@ public class TamingCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, this.skill); + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, this.skill); return textComponents; } diff --git a/src/main/java/com/gmail/nossr50/commands/skills/TridentsCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/TridentsCommand.java index b01dfdb36..af99c336f 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/TridentsCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/TridentsCommand.java @@ -50,7 +50,7 @@ public class TridentsCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.TRIDENTS); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/UnarmedCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/UnarmedCommand.java index 7ae5c1a7f..c569f2b16 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/UnarmedCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/UnarmedCommand.java @@ -136,7 +136,7 @@ public class UnarmedCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.UNARMED); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/commands/skills/WoodcuttingCommand.java b/src/main/java/com/gmail/nossr50/commands/skills/WoodcuttingCommand.java index 9b7f9ed30..173623efa 100644 --- a/src/main/java/com/gmail/nossr50/commands/skills/WoodcuttingCommand.java +++ b/src/main/java/com/gmail/nossr50/commands/skills/WoodcuttingCommand.java @@ -123,7 +123,7 @@ public class WoodcuttingCommand extends SkillCommand { protected List getTextComponents(Player player) { List textComponents = new ArrayList<>(); - TextComponentFactory.getSubSkillTextComponents(player, textComponents, + TextComponentFactory.appendSubSkillTextComponents(player, textComponents, PrimarySkillType.WOODCUTTING); return textComponents; diff --git a/src/main/java/com/gmail/nossr50/config/AdvancedConfig.java b/src/main/java/com/gmail/nossr50/config/AdvancedConfig.java index 1f44ac78b..8054bf494 100644 --- a/src/main/java/com/gmail/nossr50/config/AdvancedConfig.java +++ b/src/main/java/com/gmail/nossr50/config/AdvancedConfig.java @@ -11,6 +11,7 @@ import net.md_5.bungee.api.ChatColor; public class AdvancedConfig extends BukkitConfig { int[] defaultCrippleValues = new int[]{10, 15, 20, 25}; + int[] defaultMomentumValues = new int[]{5, 10, 15, 20, 25, 30, 35, 40, 45, 50}; public AdvancedConfig(File dataFolder) { super("advanced.yml", dataFolder); @@ -884,7 +885,17 @@ public class AdvancedConfig extends BukkitConfig { /* MACES */ public double getCrippleChanceToApplyOnHit(int rank) { - String root = "Skills.Maces.Cripple.Chance_To_Apply_On_Hit.Rank_"; - return config.getDouble(root + rank, defaultCrippleValues[rank - 1]); + return config.getDouble("Skills.Maces.Cripple.Chance_To_Apply_On_Hit.Rank_" + rank, + defaultCrippleValues[rank - 1]); + } + + /* SPEARS */ + public double getMomentumChanceToApplyOnHit(int rank) { + return config.getDouble("Skills.Spears.Momentum.Chance_To_Apply_On_Hit.Rank_" + rank, + defaultMomentumValues[rank - 1]); + } + + public double getSpearMasteryRankDamageMultiplier() { + return config.getDouble("Skills.Spears.SpearMastery.Rank_Damage_Multiplier", 0.4D); } } diff --git a/src/main/java/com/gmail/nossr50/config/GeneralConfig.java b/src/main/java/com/gmail/nossr50/config/GeneralConfig.java index dc15f412a..c6535f185 100644 --- a/src/main/java/com/gmail/nossr50/config/GeneralConfig.java +++ b/src/main/java/com/gmail/nossr50/config/GeneralConfig.java @@ -424,10 +424,6 @@ public class GeneralConfig extends BukkitConfig { return config.getBoolean("MySQL.Server.SSL", true); } - public boolean getMySQLDebug() { - return config.getBoolean("MySQL.Debug", false); - } - public boolean getMySQLPublicKeyRetrieval() { return config.getBoolean("MySQL.Server.allowPublicKeyRetrieval", true); } diff --git a/src/main/java/com/gmail/nossr50/database/DatabaseManagerFactory.java b/src/main/java/com/gmail/nossr50/database/DatabaseManagerFactory.java index d75952890..764960e11 100644 --- a/src/main/java/com/gmail/nossr50/database/DatabaseManagerFactory.java +++ b/src/main/java/com/gmail/nossr50/database/DatabaseManagerFactory.java @@ -28,8 +28,8 @@ public class DatabaseManagerFactory { : "Flatfile") + " database"); } - return mcMMO.p.getGeneralConfig().getUseMySQL() ? new SQLDatabaseManager(logger, - MYSQL_DRIVER) + return mcMMO.p.getGeneralConfig().getUseMySQL() + ? new SQLDatabaseManager(logger, MYSQL_DRIVER) : new FlatFileDatabaseManager(userFilePath, logger, purgeTime, startingLevel); } diff --git a/src/main/java/com/gmail/nossr50/database/FlatFileDataProcessor.java b/src/main/java/com/gmail/nossr50/database/FlatFileDataProcessor.java index 2331809af..888293c27 100644 --- a/src/main/java/com/gmail/nossr50/database/FlatFileDataProcessor.java +++ b/src/main/java/com/gmail/nossr50/database/FlatFileDataProcessor.java @@ -9,6 +9,7 @@ import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_GREEN_ import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_MACES; import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SERRATED_STRIKES; import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SKULL_SPLITTER; +import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SPEARS; import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SUPER_BREAKER; import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SUPER_SHOTGUN; import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_TREE_FELLER; @@ -25,6 +26,7 @@ import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_HERBALISM; import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_MACES; import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_MINING; import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_REPAIR; +import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_SPEARS; import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_SWORDS; import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TAMING; import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TRIDENTS; @@ -45,6 +47,7 @@ import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_HERBALIS import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_MACES; import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_MINING; import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_REPAIR; +import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_SPEARS; import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_SWORDS; import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TAMING; import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TRIDENTS; @@ -318,27 +321,26 @@ public class FlatFileDataProcessor { throws IndexOutOfBoundsException { return switch (dataIndex) { case USERNAME_INDEX -> - ExpectedType.STRING; //Assumption: Used to be for something, no longer used - //Assumption: Used to be for something, no longer used - //Assumption: Used to be used for something, no longer used + ExpectedType.STRING; //Assumption: Used to be used for something, no longer used case 2, 3, 23, 33, HEALTHBAR, LEGACY_LAST_LOGIN -> ExpectedType.IGNORED; case SKILLS_MINING, SKILLS_REPAIR, SKILLS_UNARMED, SKILLS_HERBALISM, SKILLS_EXCAVATION, SKILLS_ARCHERY, SKILLS_SWORDS, SKILLS_AXES, SKILLS_WOODCUTTING, SKILLS_ACROBATICS, SKILLS_TAMING, SKILLS_FISHING, - SKILLS_ALCHEMY, SKILLS_CROSSBOWS, SKILLS_TRIDENTS, SKILLS_MACES, COOLDOWN_BERSERK, + SKILLS_ALCHEMY, SKILLS_CROSSBOWS, SKILLS_TRIDENTS, SKILLS_MACES, SKILLS_SPEARS, + COOLDOWN_BERSERK, COOLDOWN_GIGA_DRILL_BREAKER, COOLDOWN_TREE_FELLER, COOLDOWN_GREEN_TERRA, COOLDOWN_SERRATED_STRIKES, COOLDOWN_SKULL_SPLITTER, COOLDOWN_SUPER_BREAKER, COOLDOWN_BLAST_MINING, SCOREBOARD_TIPS, COOLDOWN_CHIMAERA_WING, COOLDOWN_SUPER_SHOTGUN, COOLDOWN_TRIDENTS, - COOLDOWN_ARCHERY, COOLDOWN_MACES -> ExpectedType.INTEGER; + COOLDOWN_ARCHERY, COOLDOWN_MACES, COOLDOWN_SPEARS -> ExpectedType.INTEGER; case EXP_MINING, EXP_WOODCUTTING, EXP_REPAIR, EXP_UNARMED, EXP_HERBALISM, EXP_EXCAVATION, EXP_ARCHERY, EXP_SWORDS, EXP_AXES, EXP_ACROBATICS, EXP_TAMING, EXP_FISHING, EXP_ALCHEMY, EXP_CROSSBOWS, - EXP_TRIDENTS, EXP_MACES -> ExpectedType.FLOAT; + EXP_TRIDENTS, EXP_MACES, EXP_SPEARS -> ExpectedType.FLOAT; case UUID_INDEX -> ExpectedType.UUID; case OVERHAUL_LAST_LOGIN -> ExpectedType.LONG; default -> throw new IndexOutOfBoundsException(); diff --git a/src/main/java/com/gmail/nossr50/database/FlatFileDatabaseManager.java b/src/main/java/com/gmail/nossr50/database/FlatFileDatabaseManager.java index 017a3b554..ed18f0f91 100644 --- a/src/main/java/com/gmail/nossr50/database/FlatFileDatabaseManager.java +++ b/src/main/java/com/gmail/nossr50/database/FlatFileDatabaseManager.java @@ -27,6 +27,9 @@ import java.util.List; import java.util.Map; import java.util.TreeSet; import java.util.UUID; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.logging.Level; import java.util.logging.Logger; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; @@ -34,22 +37,28 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; public final class FlatFileDatabaseManager implements DatabaseManager { - public static final String IGNORED = "IGNORED"; + + static final String IGNORED = "IGNORED"; public static final String LEGACY_INVALID_OLD_USERNAME = "_INVALID_OLD_USERNAME_'"; - private final @NotNull EnumMap> leaderboardMap = new EnumMap<>( - PrimarySkillType.class); + + private static final Object fileWritingLock = new Object(); + private static final String LINE_ENDING = "\r\n"; + + private final @NotNull EnumMap> leaderboardMap = + new EnumMap<>(PrimarySkillType.class); + private @NotNull List powerLevels = new ArrayList<>(); - private long lastUpdate = 0; + private long lastUpdate = 0L; + private final @NotNull String usersFilePath; + private final @NotNull File usersFile; private final @NotNull Logger logger; private final long purgeTime; private final int startingLevel; - private final boolean testing; - private final long UPDATE_WAIT_TIME = 600000L; // 10 minutes - private final @NotNull File usersFile; - private static final Object fileWritingLock = new Object(); + private static final long UPDATE_WAIT_TIME = 600_000L; // 10 minutes + // Flatfile indices public static final int USERNAME_INDEX = 0; public static final int SKILLS_MINING = 1; public static final int EXP_MINING = 4; @@ -101,8 +110,75 @@ public final class FlatFileDatabaseManager implements DatabaseManager { public static final int EXP_MACES = 52; public static final int SKILLS_MACES = 53; public static final int COOLDOWN_MACES = 54; - //Update this everytime new data is added - public static final int DATA_ENTRY_COUNT = COOLDOWN_MACES + 1; + public static final int EXP_SPEARS = 55; + public static final int SKILLS_SPEARS = 56; + public static final int COOLDOWN_SPEARS = 57; + + // Update this everytime new data is added + public static final int DATA_ENTRY_COUNT = COOLDOWN_SPEARS + 1; + + // Maps for cleaner parsing of skills / XP / cooldowns + private record SkillIndex(PrimarySkillType type, int index) {} + private record AbilityIndex(SuperAbilityType type, int index) {} + + // All skill-level columns + private static final List SKILL_LEVEL_INDICES = List.of( + new SkillIndex(PrimarySkillType.ACROBATICS, SKILLS_ACROBATICS), + new SkillIndex(PrimarySkillType.TAMING, SKILLS_TAMING), + new SkillIndex(PrimarySkillType.MINING, SKILLS_MINING), + new SkillIndex(PrimarySkillType.REPAIR, SKILLS_REPAIR), + new SkillIndex(PrimarySkillType.WOODCUTTING, SKILLS_WOODCUTTING), + new SkillIndex(PrimarySkillType.UNARMED, SKILLS_UNARMED), + new SkillIndex(PrimarySkillType.HERBALISM, SKILLS_HERBALISM), + new SkillIndex(PrimarySkillType.EXCAVATION, SKILLS_EXCAVATION), + new SkillIndex(PrimarySkillType.ARCHERY, SKILLS_ARCHERY), + new SkillIndex(PrimarySkillType.SWORDS, SKILLS_SWORDS), + new SkillIndex(PrimarySkillType.AXES, SKILLS_AXES), + new SkillIndex(PrimarySkillType.FISHING, SKILLS_FISHING), + new SkillIndex(PrimarySkillType.ALCHEMY, SKILLS_ALCHEMY), + new SkillIndex(PrimarySkillType.CROSSBOWS, SKILLS_CROSSBOWS), + new SkillIndex(PrimarySkillType.TRIDENTS, SKILLS_TRIDENTS), + new SkillIndex(PrimarySkillType.MACES, SKILLS_MACES), + new SkillIndex(PrimarySkillType.SPEARS, SKILLS_SPEARS) + ); + + // All skill XP columns + private static final List SKILL_XP_INDICES = List.of( + new SkillIndex(PrimarySkillType.TAMING, EXP_TAMING), + new SkillIndex(PrimarySkillType.MINING, EXP_MINING), + new SkillIndex(PrimarySkillType.REPAIR, EXP_REPAIR), + new SkillIndex(PrimarySkillType.WOODCUTTING, EXP_WOODCUTTING), + new SkillIndex(PrimarySkillType.UNARMED, EXP_UNARMED), + new SkillIndex(PrimarySkillType.HERBALISM, EXP_HERBALISM), + new SkillIndex(PrimarySkillType.EXCAVATION, EXP_EXCAVATION), + new SkillIndex(PrimarySkillType.ARCHERY, EXP_ARCHERY), + new SkillIndex(PrimarySkillType.SWORDS, EXP_SWORDS), + new SkillIndex(PrimarySkillType.AXES, EXP_AXES), + new SkillIndex(PrimarySkillType.ACROBATICS, EXP_ACROBATICS), + new SkillIndex(PrimarySkillType.FISHING, EXP_FISHING), + new SkillIndex(PrimarySkillType.ALCHEMY, EXP_ALCHEMY), + new SkillIndex(PrimarySkillType.CROSSBOWS, EXP_CROSSBOWS), + new SkillIndex(PrimarySkillType.TRIDENTS, EXP_TRIDENTS), + new SkillIndex(PrimarySkillType.MACES, EXP_MACES), + new SkillIndex(PrimarySkillType.SPEARS, EXP_SPEARS) + ); + + // All ability cooldown columns + private static final List ABILITY_COOLDOWN_INDICES = List.of( + new AbilityIndex(SuperAbilityType.SUPER_BREAKER, COOLDOWN_SUPER_BREAKER), + new AbilityIndex(SuperAbilityType.TREE_FELLER, COOLDOWN_TREE_FELLER), + new AbilityIndex(SuperAbilityType.BERSERK, COOLDOWN_BERSERK), + new AbilityIndex(SuperAbilityType.GREEN_TERRA, COOLDOWN_GREEN_TERRA), + new AbilityIndex(SuperAbilityType.GIGA_DRILL_BREAKER, COOLDOWN_GIGA_DRILL_BREAKER), + new AbilityIndex(SuperAbilityType.EXPLOSIVE_SHOT, COOLDOWN_ARCHERY), + new AbilityIndex(SuperAbilityType.SERRATED_STRIKES, COOLDOWN_SERRATED_STRIKES), + new AbilityIndex(SuperAbilityType.SKULL_SPLITTER, COOLDOWN_SKULL_SPLITTER), + new AbilityIndex(SuperAbilityType.BLAST_MINING, COOLDOWN_BLAST_MINING), + new AbilityIndex(SuperAbilityType.SUPER_SHOTGUN, COOLDOWN_SUPER_SHOTGUN), + new AbilityIndex(SuperAbilityType.TRIDENTS_SUPER_ABILITY, COOLDOWN_TRIDENTS), + new AbilityIndex(SuperAbilityType.MACES_SUPER_ABILITY, COOLDOWN_MACES), + new AbilityIndex(SuperAbilityType.SPEARS_SUPER_ABILITY, COOLDOWN_SPEARS) + ); FlatFileDatabaseManager(@NotNull File usersFile, @NotNull Logger logger, long purgeTime, int startingLevel, boolean testing) { @@ -111,7 +187,6 @@ public final class FlatFileDatabaseManager implements DatabaseManager { this.logger = logger; this.purgeTime = purgeTime; this.startingLevel = startingLevel; - this.testing = testing; if (!usersFile.exists()) { initEmptyDB(); @@ -120,467 +195,309 @@ public final class FlatFileDatabaseManager implements DatabaseManager { if (!testing) { List flatFileDataFlags = checkFileHealthAndStructure(); - if (flatFileDataFlags != null) { - if (!flatFileDataFlags.isEmpty()) { - logger.info("Detected " + flatFileDataFlags.size() - + " data entries which need correction."); - } + if (flatFileDataFlags != null && !flatFileDataFlags.isEmpty()) { + logger.info("Detected " + flatFileDataFlags.size() + + " data entries which need correction."); } updateLeaderboards(); } } - FlatFileDatabaseManager(@NotNull String usersFilePath, @NotNull Logger logger, long purgeTime, + FlatFileDatabaseManager(@NotNull String usersFilePath, + @NotNull Logger logger, + long purgeTime, int startingLevel) { this(new File(usersFilePath), logger, purgeTime, startingLevel, false); } + // ------------------------------------------------------------------------ + // Purge & cleanup + // ------------------------------------------------------------------------ public int purgePowerlessUsers() { int purgedUsers = 0; LogUtils.debug(logger, "Purging powerless users..."); - BufferedReader in = null; - FileWriter out = null; - synchronized (fileWritingLock) { - try { - in = new BufferedReader(new FileReader(usersFilePath)); - StringBuilder writer = new StringBuilder(); - String line; + StringBuilder writer = new StringBuilder(); + try (BufferedReader in = newBufferedReader()) { + String line; while ((line = in.readLine()) != null) { String[] character = line.split(":"); Map skills = getSkillMapFromLine(character); - boolean powerless = true; - for (int skill : skills.values()) { - if (skill != 0) { - powerless = false; - break; - } - } + boolean powerless = skills.values().stream().allMatch(skill -> skill == 0); - // If they're still around, rewrite them to the file. if (!powerless) { - writer.append(line).append("\r\n"); + writer.append(line).append(LINE_ENDING); } else { purgedUsers++; } } - - // Write the new file - out = new FileWriter(usersFilePath); - out.write(writer.toString()); } catch (IOException e) { logger.severe("Exception while reading " + usersFilePath + " (Are you sure you formatted it correctly?)" + e); - } finally { - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore - } - } - if (out != null) { - try { - out.close(); - } catch (IOException e) { - // Ignore - } - } } + + writeStringToFileSafely(writer.toString()); } logger.info("Purged " + purgedUsers + " users from the database."); return purgedUsers; } - //TODO: Test this public void purgeOldUsers() { - int removedPlayers = 0; + int[] removedPlayers = {0}; long currentTime = System.currentTimeMillis(); LogUtils.debug(logger, "Purging old users..."); - BufferedReader in = null; - FileWriter out = null; + rewriteUsersFile(line -> { + final FlatFileRow row = FlatFileRow.parse(line, logger, usersFilePath); + if (row == null) { + // Comment / empty / malformed: keep as-is + return line; + } + + String[] data = row.fields(); + if (data.length <= UUID_INDEX) { + // Not enough fields; preserve line + return line; + } + + String uuidString = data[UUID_INDEX]; + UUID uuid = parseUuidOrNull(uuidString); + + long lastPlayed = 0L; + boolean rewrite = false; - // This code is O(n) instead of O(n²) - synchronized (fileWritingLock) { try { - in = new BufferedReader(new FileReader(usersFilePath)); - StringBuilder writer = new StringBuilder(); - String line; + lastPlayed = Long.parseLong(data[OVERHAUL_LAST_LOGIN]); + } catch (NumberFormatException e) { + logger.log(Level.SEVERE, + "Could not parse last played time for user with UUID " + uuidString + + ", attempting to correct...", e); + } - while ((line = in.readLine()) != null) { - String[] character = line.split(":"); - String uuidString = character[UUID_INDEX]; - UUID uuid = UUID.fromString(uuidString); - long lastPlayed = 0; - boolean rewrite = false; - - try { - lastPlayed = Long.parseLong(character[OVERHAUL_LAST_LOGIN]); - } catch (NumberFormatException e) { - e.printStackTrace(); - } - - if (lastPlayed == -1) { - OfflinePlayer player = mcMMO.p.getServer().getOfflinePlayer(uuid); - - if (player.getLastPlayed() != 0) { - lastPlayed = player.getLastPlayed(); - rewrite = true; - } - } - - if (lastPlayed < 1 && (currentTime - lastPlayed > purgeTime)) { - removedPlayers++; - } else { - if (rewrite) { - // Rewrite their data with a valid time - character[OVERHAUL_LAST_LOGIN] = Long.toString(lastPlayed); - String newLine = org.apache.commons.lang3.StringUtils.join(character, - ":"); - writer.append(newLine).append("\r\n"); - } else { - writer.append(line).append("\r\n"); - } - } - } - - // Write the new file - out = new FileWriter(usersFilePath); - out.write(writer.toString()); - - if (testing) { - System.out.println(writer); - } - } catch (IOException e) { - logger.severe("Exception while reading " + usersFilePath - + " (Are you sure you formatted it correctly?)" + e); - } finally { - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore - } - } - if (out != null) { - try { - out.close(); - } catch (IOException e) { - // Ignore - } + if (lastPlayed == -1 && uuid != null) { + OfflinePlayer player = mcMMO.p.getServer().getOfflinePlayer(uuid); + if (player.getLastPlayed() != 0) { + lastPlayed = player.getLastPlayed(); + rewrite = true; } } - } - logger.info("Purged " + removedPlayers + " users from the database."); + if (lastPlayed < 1 && (currentTime - lastPlayed > purgeTime)) { + removedPlayers[0]++; + return null; // drop this user + } + + if (rewrite) { + data[OVERHAUL_LAST_LOGIN] = Long.toString(lastPlayed); + return org.apache.commons.lang3.StringUtils.join(data, ":"); + } + + return line; + }); + + logger.info("Purged " + removedPlayers[0] + " users from the database."); } public boolean removeUser(String playerName, UUID uuid) { - //NOTE: UUID is unused for FlatFile for this interface implementation - boolean worked = false; + // NOTE: UUID is unused for FlatFile for this interface implementation + final String targetName = playerName; + final boolean[] worked = {false}; - BufferedReader in = null; - FileWriter out = null; - - synchronized (fileWritingLock) { - try { - in = new BufferedReader(new FileReader(usersFilePath)); - StringBuilder writer = new StringBuilder(); - String line; - - while ((line = in.readLine()) != null) { - // Write out the same file but when we get to the player we want to remove, we skip his line. - if (!worked && line.split(":")[USERNAME_INDEX].equalsIgnoreCase(playerName)) { - logger.info("User found, removing..."); - worked = true; - continue; // Skip the player - } - - writer.append(line).append("\r\n"); - } - - out = new FileWriter(usersFilePath); // Write out the new file - out.write(writer.toString()); - } catch (Exception e) { - logger.severe("Exception while reading " + usersFilePath - + " (Are you sure you formatted it correctly?)" + e); - } finally { - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore - } - } - if (out != null) { - try { - out.close(); - } catch (IOException e) { - // Ignore - } - } + rewriteUsersFile(line -> { + FlatFileRow row = FlatFileRow.parse(line, logger, usersFilePath); + if (row == null) { + return line; // comments / malformed stay } - } + + if (!worked[0] && row.username().equalsIgnoreCase(targetName)) { + logger.info("User found, removing..."); + worked[0] = true; + return null; // drop this line + } + + return line; + }); Misc.profileCleanup(playerName); - - return worked; + return worked[0]; } @Override public void cleanupUser(UUID uuid) { - //Not used in FlatFile + // Not used in FlatFile } + // ------------------------------------------------------------------------ + // Save / load users + // ------------------------------------------------------------------------ + public boolean saveUser(@NotNull PlayerProfile profile) { String playerName = profile.getPlayerName(); UUID uuid = profile.getUniqueId(); - BufferedReader in = null; - FileWriter out = null; boolean corruptDataFound = false; synchronized (fileWritingLock) { - try { - // Open the file - in = new BufferedReader(new FileReader(usersFilePath)); - StringBuilder writer = new StringBuilder(); - String line; + StringBuilder writer = new StringBuilder(); + try (BufferedReader in = newBufferedReader()) { + String line; boolean wroteUser = false; - // While not at the end of the file + while ((line = in.readLine()) != null) { if (line.startsWith("#")) { - writer.append(line).append("\r\n"); + writer.append(line).append(LINE_ENDING); continue; } - //Check for incomplete or corrupted data if (!line.contains(":")) { - - if (!corruptDataFound) { - logger.severe( - "mcMMO found some unexpected or corrupted data in mcmmo.users and is removing it, it is possible some data has been lost."); - corruptDataFound = true; - } - + corruptDataFound = logCorruptOnce(corruptDataFound); continue; } String[] splitData = line.split(":"); - //This would be rare, but check the splitData for having enough entries to contain a UUID - if (splitData.length - < UUID_INDEX) { //UUID have been in mcMMO DB for a very long time so any user without - - if (!corruptDataFound) { - logger.severe( - "mcMMO found some unexpected or corrupted data in mcmmo.users and is removing it, it is possible some data has been lost."); - corruptDataFound = true; - } - + // Not enough entries to even contain a UUID + if (splitData.length < UUID_INDEX) { + corruptDataFound = logCorruptOnce(corruptDataFound); continue; } - if (!(uuid != null - && splitData[UUID_INDEX].equalsIgnoreCase(uuid.toString())) - && !splitData[USERNAME_INDEX].equalsIgnoreCase(playerName)) { - writer.append(line) - .append("\r\n"); //Not the user so write it to file and move on + boolean uuidMatches = uuid != null + && splitData.length > UUID_INDEX + && splitData[UUID_INDEX].equalsIgnoreCase(uuid.toString()); + boolean nameMatches = splitData.length > USERNAME_INDEX + && splitData[USERNAME_INDEX].equalsIgnoreCase(playerName); + + if (!uuidMatches && !nameMatches) { + // not the user, keep the line + writer.append(line).append(LINE_ENDING); } else { - //User found writeUserToLine(profile, writer); wroteUser = true; } } - /* - * If we couldn't find the user in the DB we need to add him - */ if (!wroteUser) { writeUserToLine(profile, writer); } - // Write the new file - out = new FileWriter(usersFilePath); - out.write(writer.toString()); + writeStringToFileSafely(writer.toString()); return true; } catch (Exception e) { - e.printStackTrace(); + logger.log(Level.SEVERE, + "Unexpected Exception while reading " + usersFilePath, e); return false; - } finally { - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore - } - } - if (out != null) { - try { - out.close(); - } catch (IOException e) { - // Ignore - } - } } } } - public void writeUserToLine(@NotNull PlayerProfile profile, @NotNull Appendable appendable) - throws IOException { - appendable.append(profile.getPlayerName()).append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.MINING))) - .append(":"); - appendable.append(IGNORED).append(":"); - appendable.append(IGNORED).append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.MINING))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.WOODCUTTING))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.WOODCUTTING))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.REPAIR))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.UNARMED))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.HERBALISM))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.EXCAVATION))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.ARCHERY))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.SWORDS))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.AXES))).append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.ACROBATICS))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.REPAIR))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.UNARMED))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.HERBALISM))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.EXCAVATION))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.ARCHERY))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.SWORDS))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.AXES))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.ACROBATICS))) - .append(":"); - appendable.append(IGNORED).append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.TAMING))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.TAMING))) - .append(":"); - appendable.append(String.valueOf(profile.getAbilityDATS(SuperAbilityType.BERSERK))) - .append(":"); - appendable.append( - String.valueOf(profile.getAbilityDATS(SuperAbilityType.GIGA_DRILL_BREAKER))) - .append(":"); - appendable.append(String.valueOf(profile.getAbilityDATS(SuperAbilityType.TREE_FELLER))) - .append(":"); - appendable.append(String.valueOf(profile.getAbilityDATS(SuperAbilityType.GREEN_TERRA))) - .append(":"); - appendable.append(String.valueOf(profile.getAbilityDATS(SuperAbilityType.SERRATED_STRIKES))) - .append(":"); - appendable.append(String.valueOf(profile.getAbilityDATS(SuperAbilityType.SKULL_SPLITTER))) - .append(":"); - appendable.append(String.valueOf(profile.getAbilityDATS(SuperAbilityType.SUPER_BREAKER))) - .append(":"); - appendable.append(IGNORED).append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.FISHING))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.FISHING))) - .append(":"); - appendable.append(String.valueOf(profile.getAbilityDATS(SuperAbilityType.BLAST_MINING))) - .append(":"); - appendable.append(IGNORED).append(":"); //Legacy last login - appendable.append(IGNORED).append(":"); //mob health bar - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.ALCHEMY))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.ALCHEMY))) - .append(":"); - appendable.append(profile.getUniqueId() != null ? profile.getUniqueId().toString() : "NULL") - .append(":"); - appendable.append(String.valueOf(profile.getScoreboardTipsShown())).append(":"); - appendable.append(String.valueOf(profile.getUniqueData(UniqueDataType.CHIMAERA_WING_DATS))) - .append(":"); - appendable.append(String.valueOf(profile.getLastLogin())).append(":"); //overhaul last login - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.CROSSBOWS))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.CROSSBOWS))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.TRIDENTS))) - .append(":"); - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.TRIDENTS))) - .append(":"); - // public static final int COOLDOWN_SUPER_SHOTGUN = 49; - appendable.append(String.valueOf(profile.getAbilityDATS(SuperAbilityType.SUPER_SHOTGUN))) - .append(":"); - // public static final int COOLDOWN_TRIDENTS = 50; - appendable.append( - String.valueOf(profile.getAbilityDATS(SuperAbilityType.TRIDENTS_SUPER_ABILITY))) - .append(":"); - // public static final int COOLDOWN_ARCHERY = 51; - appendable.append(String.valueOf(profile.getAbilityDATS(SuperAbilityType.EXPLOSIVE_SHOT))) - .append(":"); - // public static final int EXP_MACES = 52; - appendable.append(String.valueOf(profile.getSkillXpLevel(PrimarySkillType.MACES))) - .append(":"); - // public static final int SKILLS_MACES = 53; - appendable.append(String.valueOf(profile.getSkillLevel(PrimarySkillType.MACES))) - .append(":"); - // public static final int COOLDOWN_MACES = 54; - appendable.append( - String.valueOf(profile.getAbilityDATS(SuperAbilityType.MACES_SUPER_ABILITY))) - .append(":"); - appendable.append("\r\n"); - } - - public @NotNull List readLeaderboard(@Nullable PrimarySkillType primarySkillType, - int pageNumber, int statsPerPage) throws InvalidSkillException { - //Fix for a plugin that people are using that is throwing SQL errors - if (primarySkillType != null && SkillTools.isChildSkill(primarySkillType)) { + private boolean logCorruptOnce(boolean alreadyLogged) { + if (!alreadyLogged) { logger.severe( - "A plugin hooking into mcMMO is being naughty with our database commands, update all plugins that hook into mcMMO and contact their devs!"); - throw new InvalidSkillException( - "A plugin hooking into mcMMO that you are using is attempting to read leaderboard skills for child skills, child skills do not have leaderboards! This is NOT an mcMMO error!"); + "mcMMO found some unexpected or corrupted data in mcmmo.users and is removing it, it is possible some data has been lost."); } - - updateLeaderboards(); - List statsList = - primarySkillType == null ? powerLevels : leaderboardMap.get(primarySkillType); - int fromIndex = (Math.max(pageNumber, 1) - 1) * statsPerPage; - - return statsList.subList(Math.min(fromIndex, statsList.size()), - Math.min(fromIndex + statsPerPage, statsList.size())); + return true; } - public @NotNull HashMap readRank(String playerName) { - updateLeaderboards(); + public void writeUserToLine(@NotNull PlayerProfile profile, + @NotNull Appendable out) throws IOException { - HashMap skills = new HashMap<>(); + // Username + appendString(out, profile.getPlayerName()); - for (PrimarySkillType skill : SkillTools.NON_CHILD_SKILLS) { - skills.put(skill, getPlayerRank(playerName, leaderboardMap.get(skill))); - } + // MINING level / placeholders / XP + appendInt(out, profile.getSkillLevel(PrimarySkillType.MINING)); + appendIgnored(out); // old data + appendIgnored(out); // old data + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.MINING)); - skills.put(null, getPlayerRank(playerName, powerLevels)); + // WOODCUTTING + appendInt(out, profile.getSkillLevel(PrimarySkillType.WOODCUTTING)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.WOODCUTTING)); - return skills; + // REPAIR / UNARMED / HERBALISM / EXCAVATION / ARCHERY / SWORDS / AXES / ACROBATICS + appendInt(out, profile.getSkillLevel(PrimarySkillType.REPAIR)); + appendInt(out, profile.getSkillLevel(PrimarySkillType.UNARMED)); + appendInt(out, profile.getSkillLevel(PrimarySkillType.HERBALISM)); + appendInt(out, profile.getSkillLevel(PrimarySkillType.EXCAVATION)); + appendInt(out, profile.getSkillLevel(PrimarySkillType.ARCHERY)); + appendInt(out, profile.getSkillLevel(PrimarySkillType.SWORDS)); + appendInt(out, profile.getSkillLevel(PrimarySkillType.AXES)); + appendInt(out, profile.getSkillLevel(PrimarySkillType.ACROBATICS)); + + // XP for same block + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.REPAIR)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.UNARMED)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.HERBALISM)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.EXCAVATION)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.ARCHERY)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.SWORDS)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.AXES)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.ACROBATICS)); + + // Placeholder, TAMING, TAMING XP + appendIgnored(out); + appendInt(out, profile.getSkillLevel(PrimarySkillType.TAMING)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.TAMING)); + + // Ability cooldowns + appendLong(out, profile.getAbilityDATS(SuperAbilityType.BERSERK)); + appendLong(out, profile.getAbilityDATS(SuperAbilityType.GIGA_DRILL_BREAKER)); + appendLong(out, profile.getAbilityDATS(SuperAbilityType.TREE_FELLER)); + appendLong(out, profile.getAbilityDATS(SuperAbilityType.GREEN_TERRA)); + appendLong(out, profile.getAbilityDATS(SuperAbilityType.SERRATED_STRIKES)); + appendLong(out, profile.getAbilityDATS(SuperAbilityType.SKULL_SPLITTER)); + appendLong(out, profile.getAbilityDATS(SuperAbilityType.SUPER_BREAKER)); + + // Placeholder + appendIgnored(out); + + // FISHING + appendInt(out, profile.getSkillLevel(PrimarySkillType.FISHING)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.FISHING)); + appendLong(out, profile.getAbilityDATS(SuperAbilityType.BLAST_MINING)); + + // Legacy / mob health bar + appendIgnored(out); // Legacy last login + appendIgnored(out); // Mob health bar + + // ALCHEMY + appendInt(out, profile.getSkillLevel(PrimarySkillType.ALCHEMY)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.ALCHEMY)); + + // UUID + appendString(out, profile.getUniqueId() != null ? profile.getUniqueId().toString() : "NULL"); + + // Misc data + appendInt(out, profile.getScoreboardTipsShown()); + appendLong(out, profile.getUniqueData(UniqueDataType.CHIMAERA_WING_DATS)); + appendLong(out, profile.getLastLogin()); + + // CROSSBOWS / TRIDENTS / MACES / SPEARS XP + levels + ability cooldowns + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.CROSSBOWS)); + appendInt(out, profile.getSkillLevel(PrimarySkillType.CROSSBOWS)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.TRIDENTS)); + appendInt(out, profile.getSkillLevel(PrimarySkillType.TRIDENTS)); + appendLong(out, profile.getAbilityDATS(SuperAbilityType.SUPER_SHOTGUN)); + appendLong(out, profile.getAbilityDATS(SuperAbilityType.TRIDENTS_SUPER_ABILITY)); + appendLong(out, profile.getAbilityDATS(SuperAbilityType.EXPLOSIVE_SHOT)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.MACES)); + appendInt(out, profile.getSkillLevel(PrimarySkillType.MACES)); + appendLong(out, profile.getAbilityDATS(SuperAbilityType.MACES_SUPER_ABILITY)); + appendInt(out, profile.getSkillXpLevel(PrimarySkillType.SPEARS)); + appendInt(out, profile.getSkillLevel(PrimarySkillType.SPEARS)); + appendLong(out, profile.getAbilityDATS(SuperAbilityType.SPEARS_SUPER_ABILITY)); + + out.append(LINE_ENDING); } public @NotNull PlayerProfile newUser(@NotNull Player player) { @@ -591,25 +508,23 @@ public final class FlatFileDatabaseManager implements DatabaseManager { PlayerProfile playerProfile = new PlayerProfile(playerName, uuid, true, startingLevel); synchronized (fileWritingLock) { - try (BufferedReader bufferedReader = new BufferedReader( - new FileReader(usersFilePath))) { - StringBuilder stringBuilder = new StringBuilder(); + StringBuilder stringBuilder = new StringBuilder(); + try (BufferedReader bufferedReader = newBufferedReader()) { String line; - - //Build up the file while ((line = bufferedReader.readLine()) != null) { - stringBuilder.append(line).append("\r\n"); - } - - try (FileWriter fileWriter = new FileWriter(usersFile)) { - writeUserToLine(playerProfile, stringBuilder); - fileWriter.write(stringBuilder.toString()); - } catch (Exception e) { - e.printStackTrace(); + stringBuilder.append(line).append(LINE_ENDING); } } catch (IOException e) { - e.printStackTrace(); + logger.log(Level.SEVERE, "Unexpected Exception while reading " + usersFilePath, e); + } + + try (FileWriter fileWriter = new FileWriter(usersFile)) { + writeUserToLine(playerProfile, stringBuilder); + fileWriter.write(stringBuilder.toString()); + } catch (Exception e) { + logger.log(Level.SEVERE, + "Unexpected Exception while writing to " + usersFilePath, e); } } @@ -628,8 +543,8 @@ public final class FlatFileDatabaseManager implements DatabaseManager { return processUserQuery(getUserQuery(uuid, null)); } - private @NotNull UserQuery getUserQuery(@Nullable UUID uuid, @Nullable String playerName) - throws NullPointerException { + private @NotNull UserQuery getUserQuery(@Nullable UUID uuid, + @Nullable String playerName) { boolean hasName = playerName != null && !playerName.equalsIgnoreCase("null"); if (hasName && uuid != null) { @@ -644,15 +559,7 @@ public final class FlatFileDatabaseManager implements DatabaseManager { } } - /** - * Find and load a player by UUID/Name If the name isn't null and doesn't match the name in the - * DB, the players name is then replaced/updated - * - * @param userQuery the query - * @return a profile with the targets data or an unloaded profile if no data was found - */ - private @NotNull PlayerProfile processUserQuery(@NotNull UserQuery userQuery) - throws RuntimeException { + private @NotNull PlayerProfile processUserQuery(@NotNull UserQuery userQuery) { return switch (userQuery.getType()) { case UUID_AND_NAME -> queryByUUIDAndName((UserQueryFull) userQuery); case UUID -> queryByUUID((UserQueryUUID) userQuery); @@ -662,12 +569,9 @@ public final class FlatFileDatabaseManager implements DatabaseManager { private @NotNull PlayerProfile queryByName(@NotNull UserQueryName userQuery) { String playerName = userQuery.getName(); - BufferedReader in = null; synchronized (fileWritingLock) { - try { - // Open the user file - in = new BufferedReader(new FileReader(usersFilePath)); + try (BufferedReader in = newBufferedReader()) { String line; while ((line = in.readLine()) != null) { @@ -675,57 +579,39 @@ public final class FlatFileDatabaseManager implements DatabaseManager { continue; } - // Find if the line contains the player we want. String[] rawSplitData = line.split(":"); - - /* Don't read corrupt data */ if (rawSplitData.length < (USERNAME_INDEX + 1)) { continue; } - // we found the player if (playerName.equalsIgnoreCase(rawSplitData[USERNAME_INDEX])) { return loadFromLine(rawSplitData); } } } catch (Exception e) { - e.printStackTrace(); - } finally { - // I have no idea why it's necessary to inline tryClose() here, but it removes - // a resource leak warning, and I'm trusting the compiler on this one. - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore - } - } + logger.log(Level.SEVERE, + "Unexpected Exception while reading " + usersFilePath, e); } } - //Return a new blank profile - return new PlayerProfile(playerName, new UUID(0, 0), startingLevel); + return new PlayerProfile(playerName, new UUID(0L, 0L), startingLevel); } private @NotNull PlayerProfile queryByUUID(@NotNull UserQueryUUID userQuery) { - BufferedReader in = null; UUID uuid = userQuery.getUUID(); synchronized (fileWritingLock) { - try { - // Open the user file - in = new BufferedReader(new FileReader(usersFilePath)); + try (BufferedReader in = newBufferedReader()) { String line; while ((line = in.readLine()) != null) { if (line.startsWith("#")) { continue; } - // Find if the line contains the player we want. + String[] rawSplitData = line.split(":"); - /* Don't read corrupt data */ if (rawSplitData.length < (UUID_INDEX + 1)) { continue; } @@ -736,52 +622,33 @@ public final class FlatFileDatabaseManager implements DatabaseManager { return loadFromLine(rawSplitData); } } catch (Exception e) { - if (testing) { - e.printStackTrace(); - } + // Ignore malformed UUIDs } } } catch (Exception e) { - e.printStackTrace(); - } finally { - // I have no idea why it's necessary to inline tryClose() here, but it removes - // a resource leak warning, and I'm trusting the compiler on this one. - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore - } - } + logger.log(Level.SEVERE, + "Unexpected Exception while reading " + usersFilePath, e); } } - /* - * No match was found in the file - */ - return grabUnloadedProfile(uuid, "Player-Not-Found=" + uuid); } private @NotNull PlayerProfile queryByUUIDAndName(@NotNull UserQueryFull userQuery) { - BufferedReader in = null; String playerName = userQuery.getName(); UUID uuid = userQuery.getUUID(); synchronized (fileWritingLock) { - try { - // Open the user file - in = new BufferedReader(new FileReader(usersFilePath)); + try (BufferedReader in = newBufferedReader()) { String line; while ((line = in.readLine()) != null) { if (line.startsWith("#")) { continue; } - // Find if the line contains the player we want. + String[] rawSplitData = line.split(":"); - /* Don't read corrupt data */ if (rawSplitData.length < (UUID_INDEX + 1)) { continue; } @@ -789,74 +656,54 @@ public final class FlatFileDatabaseManager implements DatabaseManager { try { UUID fromDataUUID = UUID.fromString(rawSplitData[UUID_INDEX]); if (fromDataUUID.equals(uuid)) { - //Matched UUID, now check if name matches String dbPlayerName = rawSplitData[USERNAME_INDEX]; - boolean matchingName = dbPlayerName.equalsIgnoreCase(playerName); if (!matchingName) { logger.warning( "When loading user: " + playerName + " with UUID of (" - + uuid - + ") we found a mismatched name, the name in the DB will be replaced (DB name: " + + uuid + ") we found a mismatched name, the name in the DB will be replaced (DB name: " + dbPlayerName + ")"); - //logger.info("Name updated for player: " + rawSplitData[USERNAME_INDEX] + " => " + playerName); rawSplitData[USERNAME_INDEX] = playerName; } - //TODO: Logic to replace name here return loadFromLine(rawSplitData); } } catch (Exception e) { - if (testing) { - e.printStackTrace(); - } - } - } - } catch (Exception e) { - e.printStackTrace(); - } finally { - // I have no idea why it's necessary to inline tryClose() here, but it removes - // a resource leak warning, and I'm trusting the compiler on this one. - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore + // Ignore malformed UUIDs } } + } catch (IOException e) { + logger.log(Level.SEVERE, + "Unexpected Exception while reading " + usersFilePath, e); } } - /* - * No match was found in the file - */ - - return grabUnloadedProfile(uuid, playerName); //Create an empty new profile and return + return grabUnloadedProfile(uuid, playerName); } private @NotNull PlayerProfile grabUnloadedProfile(@NotNull UUID uuid, @Nullable String playerName) { - if (playerName == null) { - playerName = ""; //No name for you boy! - } - - return new PlayerProfile(playerName, uuid, 0); + String name = (playerName == null) ? "" : playerName; + return new PlayerProfile(name, uuid, 0); } + // ------------------------------------------------------------------------ + // Conversion / UUID updates + // ------------------------------------------------------------------------ + public void convertUsers(DatabaseManager destination) { - BufferedReader in = null; int convertedUsers = 0; long startMillis = System.currentTimeMillis(); synchronized (fileWritingLock) { - try { - // Open the user file - in = new BufferedReader(new FileReader(usersFilePath)); + try (BufferedReader reader = newBufferedReader()) { String line; - while ((line = in.readLine()) != null) { - if (line.startsWith("#")) { + while ((line = reader.readLine()) != null) { + line = line.trim(); + + if (line.isEmpty() || line.startsWith("#")) { continue; } @@ -865,390 +712,237 @@ public final class FlatFileDatabaseManager implements DatabaseManager { try { destination.saveUser(loadFromLine(character)); } catch (Exception e) { - e.printStackTrace(); + String username = (character.length > USERNAME_INDEX) + ? character[USERNAME_INDEX] + : ""; + logger.log(Level.SEVERE, + "Could not convert user from FlatFile to SQL DB: " + username, e); } + convertedUsers++; Misc.printProgress(convertedUsers, progressInterval, startMillis); } - } catch (Exception e) { - e.printStackTrace(); - } finally { - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore - } - } + } catch (IOException e) { + logger.log(Level.SEVERE, + "Failed to convert users from FlatFile to SQL DB", e); } } } public boolean saveUserUUID(String userName, UUID uuid) { boolean worked = false; - - int i = 0; - BufferedReader in = null; - FileWriter out = null; + int entriesWritten = 0; synchronized (fileWritingLock) { - try { - in = new BufferedReader(new FileReader(usersFilePath)); - StringBuilder writer = new StringBuilder(); + StringBuilder writer = new StringBuilder(); + + try (BufferedReader in = newBufferedReader()) { String line; while ((line = in.readLine()) != null) { String[] character = line.split(":"); - if (!worked && character[USERNAME_INDEX].equalsIgnoreCase(userName)) { + + if (!worked && character.length > USERNAME_INDEX + && character[USERNAME_INDEX].equalsIgnoreCase(userName)) { if (character.length < 42) { logger.severe("Could not update UUID for " + userName + "!"); logger.severe("Database entry is invalid."); - continue; + // still append original line + } else { + line = line.replace(character[UUID_INDEX], uuid.toString()); + worked = true; } - - line = line.replace(character[UUID_INDEX], uuid.toString()); - worked = true; } - i++; - writer.append(line).append("\r\n"); + entriesWritten++; + writer.append(line).append(LINE_ENDING); } - - out = new FileWriter(usersFilePath); // Write out the new file - out.write(writer.toString()); } catch (Exception e) { logger.severe("Exception while reading " + usersFilePath + " (Are you sure you formatted it correctly?)" + e); - } finally { - LogUtils.debug(logger, i + " entries written while saving UUID for " + userName); - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore - } - } - if (out != null) { - try { - out.close(); - } catch (IOException e) { - // Ignore - } - } } + + LogUtils.debug(logger, + entriesWritten + " entries written while saving UUID for " + userName); + writeStringToFileSafely(writer.toString()); } return worked; } public boolean saveUserUUIDs(Map fetchedUUIDs) { - BufferedReader in = null; - FileWriter out = null; - int i = 0; + int[] entriesWritten = {0}; - synchronized (fileWritingLock) { - try { - in = new BufferedReader(new FileReader(usersFilePath)); - StringBuilder writer = new StringBuilder(); - String line; + rewriteUsersFile(line -> { + FlatFileRow row = FlatFileRow.parse(line, logger, usersFilePath); + if (row == null) { + entriesWritten[0]++; + return line; // comments / empty / malformed unchanged + } - while (((line = in.readLine()) != null)) { - String[] character = line.split(":"); - if (!fetchedUUIDs.isEmpty() && fetchedUUIDs.containsKey( - character[USERNAME_INDEX])) { - if (character.length < 42) { - logger.severe( - "Could not update UUID for " + character[USERNAME_INDEX] + "!"); - logger.severe("Database entry is invalid."); - continue; - } + String[] character = row.fields(); + String username = row.username(); - character[UUID_INDEX] = fetchedUUIDs.remove(character[USERNAME_INDEX]) - .toString(); - line = org.apache.commons.lang3.StringUtils.join(character, ":") + ":"; - } - - i++; - writer.append(line).append("\r\n"); - } - - out = new FileWriter(usersFilePath); // Write out the new file - out.write(writer.toString()); - } catch (Exception e) { - logger.severe("Exception while reading " + usersFilePath - + " (Are you sure you formatted it correctly?)" + e); - } finally { - LogUtils.debug(logger, i + " entries written while saving UUID batch"); - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore - } - } - if (out != null) { - try { - out.close(); - } catch (IOException e) { - // Ignore - } + if (username != null && fetchedUUIDs.containsKey(username)) { + if (character.length < 42) { + logger.severe("Could not update UUID for " + username + "!"); + logger.severe("Database entry is invalid."); + } else { + character[UUID_INDEX] = fetchedUUIDs.remove(username).toString(); + String updated = org.apache.commons.lang3.StringUtils.join(character, ":") + ":"; + entriesWritten[0]++; + return updated; } } - } + entriesWritten[0]++; + return line; + }); + + LogUtils.debug(logger, + entriesWritten[0] + " entries written while saving UUID batch"); return true; } public List getStoredUsers() { ArrayList users = new ArrayList<>(); - BufferedReader in = null; - synchronized (fileWritingLock) { - try { - // Open the user file - in = new BufferedReader(new FileReader(usersFilePath)); - String line; - - while ((line = in.readLine()) != null) { - String[] character = line.split(":"); - users.add(character[USERNAME_INDEX]); - } - } catch (Exception e) { - e.printStackTrace(); - } finally { - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore - } - } + withUsersFileLines(line -> { + String[] character = line.split(":"); + if (character.length > USERNAME_INDEX) { + users.add(character[USERNAME_INDEX]); } - } + }); + return users; } - /** - * Update the leader boards. - */ + // ------------------------------------------------------------------------ + // Leaderboards + // ------------------------------------------------------------------------ + public @NotNull LeaderboardStatus updateLeaderboards() { - // Only update FFS leaderboards every 10 minutes.. this puts a lot of strain on the server (depending on the size of the database) and should not be done frequently - if (System.currentTimeMillis() < lastUpdate + UPDATE_WAIT_TIME) { + long now = System.currentTimeMillis(); + if (now < lastUpdate + UPDATE_WAIT_TIME) { return LeaderboardStatus.TOO_SOON_TO_UPDATE; } - lastUpdate = System.currentTimeMillis(); // Log when the last update was run + lastUpdate = now; + // Power level leaderboard TreeSet powerLevelStats = new TreeSet<>(); - TreeSet mining = new TreeSet<>(); - TreeSet woodcutting = new TreeSet<>(); - TreeSet herbalism = new TreeSet<>(); - TreeSet excavation = new TreeSet<>(); - TreeSet acrobatics = new TreeSet<>(); - TreeSet repair = new TreeSet<>(); - TreeSet swords = new TreeSet<>(); - TreeSet axes = new TreeSet<>(); - TreeSet archery = new TreeSet<>(); - TreeSet unarmed = new TreeSet<>(); - TreeSet taming = new TreeSet<>(); - TreeSet fishing = new TreeSet<>(); - TreeSet alchemy = new TreeSet<>(); - TreeSet crossbows = new TreeSet<>(); - TreeSet tridents = new TreeSet<>(); - TreeSet maces = new TreeSet<>(); - BufferedReader in = null; + // Per-skill leaderboards + EnumMap> perSkillSets = + new EnumMap<>(PrimarySkillType.class); + + perSkillSets.put(PrimarySkillType.MINING, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.WOODCUTTING, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.REPAIR, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.UNARMED, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.HERBALISM, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.EXCAVATION, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.ARCHERY, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.SWORDS, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.AXES, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.ACROBATICS, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.TAMING, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.FISHING, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.ALCHEMY, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.CROSSBOWS, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.TRIDENTS, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.MACES, new TreeSet<>()); + perSkillSets.put(PrimarySkillType.SPEARS, new TreeSet<>()); + String playerName = null; - // Read from the FlatFile database and fill our arrays with information + synchronized (fileWritingLock) { - try { - in = new BufferedReader(new FileReader(usersFilePath)); + try (BufferedReader in = newBufferedReader()) { String line; - while ((line = in.readLine()) != null) { - - if (line.startsWith("#")) { - continue; + FlatFileRow row = FlatFileRow.parse(line, logger, usersFilePath); + if (row == null) { + continue; // comment / empty / malformed } - String[] data = line.split(":"); - playerName = data[USERNAME_INDEX]; - int powerLevel = 0; + playerName = row.username(); + String[] data = row.fields(); Map skills = getSkillMapFromLine(data); - - powerLevel += putStat(acrobatics, playerName, - skills.get(PrimarySkillType.ACROBATICS)); - powerLevel += putStat(alchemy, playerName, - skills.get(PrimarySkillType.ALCHEMY)); - powerLevel += putStat(archery, playerName, - skills.get(PrimarySkillType.ARCHERY)); - powerLevel += putStat(axes, playerName, skills.get(PrimarySkillType.AXES)); - powerLevel += putStat(excavation, playerName, - skills.get(PrimarySkillType.EXCAVATION)); - powerLevel += putStat(fishing, playerName, - skills.get(PrimarySkillType.FISHING)); - powerLevel += putStat(herbalism, playerName, - skills.get(PrimarySkillType.HERBALISM)); - powerLevel += putStat(mining, playerName, skills.get(PrimarySkillType.MINING)); - powerLevel += putStat(repair, playerName, skills.get(PrimarySkillType.REPAIR)); - powerLevel += putStat(swords, playerName, skills.get(PrimarySkillType.SWORDS)); - powerLevel += putStat(taming, playerName, skills.get(PrimarySkillType.TAMING)); - powerLevel += putStat(unarmed, playerName, - skills.get(PrimarySkillType.UNARMED)); - powerLevel += putStat(woodcutting, playerName, - skills.get(PrimarySkillType.WOODCUTTING)); - powerLevel += putStat(crossbows, playerName, - skills.get(PrimarySkillType.CROSSBOWS)); - powerLevel += putStat(tridents, playerName, - skills.get(PrimarySkillType.TRIDENTS)); - powerLevel += putStat(maces, playerName, skills.get(PrimarySkillType.MACES)); - + int powerLevel = addAllSkillStats(playerName, skills, perSkillSets); putStat(powerLevelStats, playerName, powerLevel); } - } catch (Exception e) { - logger.severe( - "Exception while reading " + usersFilePath + " during user " + playerName - + " (Are you sure you formatted it correctly?) " + e); + } catch (IOException e) { + logger.severe("Exception while reading " + usersFilePath + " during user " + + playerName + " (Are you sure you formatted it correctly?) " + e); return LeaderboardStatus.FAILED; - } finally { - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore - } - } } - } + // Freeze current leaderboards as immutable lists powerLevels = List.copyOf(powerLevelStats); - leaderboardMap.put(PrimarySkillType.MINING, List.copyOf(mining)); - leaderboardMap.put(PrimarySkillType.WOODCUTTING, List.copyOf(woodcutting)); - leaderboardMap.put(PrimarySkillType.REPAIR, List.copyOf(repair)); - leaderboardMap.put(PrimarySkillType.UNARMED, List.copyOf(unarmed)); - leaderboardMap.put(PrimarySkillType.HERBALISM, List.copyOf(herbalism)); - leaderboardMap.put(PrimarySkillType.EXCAVATION, List.copyOf(excavation)); - leaderboardMap.put(PrimarySkillType.ARCHERY, List.copyOf(archery)); - leaderboardMap.put(PrimarySkillType.SWORDS, List.copyOf(swords)); - leaderboardMap.put(PrimarySkillType.AXES, List.copyOf(axes)); - leaderboardMap.put(PrimarySkillType.ACROBATICS, List.copyOf(acrobatics)); - leaderboardMap.put(PrimarySkillType.TAMING, List.copyOf(taming)); - leaderboardMap.put(PrimarySkillType.FISHING, List.copyOf(fishing)); - leaderboardMap.put(PrimarySkillType.ALCHEMY, List.copyOf(alchemy)); - leaderboardMap.put(PrimarySkillType.CROSSBOWS, List.copyOf(crossbows)); - leaderboardMap.put(PrimarySkillType.TRIDENTS, List.copyOf(tridents)); - leaderboardMap.put(PrimarySkillType.MACES, List.copyOf(maces)); + + for (Map.Entry> entry : perSkillSets.entrySet()) { + leaderboardMap.put(entry.getKey(), List.copyOf(entry.getValue())); + } return LeaderboardStatus.UPDATED; } - private void initEmptyDB() { - BufferedWriter bufferedWriter = null; - synchronized (fileWritingLock) { - try { - // Open the file to write the player - bufferedWriter = new BufferedWriter(new FileWriter(usersFilePath, true)); - DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern( - "MM/dd/yyyy HH:mm"); - LocalDateTime localDateTime = LocalDateTime.now(); - bufferedWriter.append("# mcMMO Database created on ") - .append(localDateTime.format(dateTimeFormatter)) - .append("\r\n"); //Empty file - } catch (Exception e) { - e.printStackTrace(); - } finally { - if (bufferedWriter != null) { - try { - bufferedWriter.close(); - } catch (IOException e) { - // Ignore - } - } - } + private int addAllSkillStats(String playerName, + Map skills, + Map> perSkillSets) { + int powerLevel = 0; + + for (Map.Entry> entry : perSkillSets.entrySet()) { + PrimarySkillType skill = entry.getKey(); + TreeSet set = entry.getValue(); + + int value = skills.getOrDefault(skill, 0); + powerLevel += putStat(set, playerName, value); } + + return powerLevel; } - public @Nullable List checkFileHealthAndStructure() { - ArrayList flagsFound = null; - LogUtils.debug(logger, "(" + usersFile.getPath() + ") Validating database file.."); - FlatFileDataProcessor dataProcessor; + public @NotNull List readLeaderboard(@Nullable PrimarySkillType primarySkillType, + int pageNumber, + int statsPerPage) throws InvalidSkillException { - if (usersFile.exists()) { - BufferedReader bufferedReader = null; - FileWriter fileWriter = null; - - synchronized (fileWritingLock) { - - dataProcessor = new FlatFileDataProcessor(logger); - - try { - String currentLine; - String dbCommentDate = null; - - bufferedReader = new BufferedReader(new FileReader(usersFilePath)); - - //Analyze the data - while ((currentLine = bufferedReader.readLine()) != null) { - //Commented lines - if (currentLine.startsWith("#") && dbCommentDate - == null) { //The first commented line in the file is likely to be our note about when the file was created - dbCommentDate = currentLine; - continue; - } - - if (currentLine.isEmpty()) { - continue; - } - - //TODO: We are never passing empty lines, should we remove the flag for them? - dataProcessor.processData(currentLine); - } - - //Only update the file if needed - if (!dataProcessor.getFlatFileDataFlags().isEmpty()) { - flagsFound = new ArrayList<>(dataProcessor.getFlatFileDataFlags()); - logger.info("Updating FlatFile Database..."); - fileWriter = new FileWriter(usersFilePath); - //Write data to file - if (dbCommentDate != null) { - fileWriter.write(dbCommentDate + "\r\n"); - } - - fileWriter.write(dataProcessor.processDataForSave().toString()); - } - } catch (IOException e) { - e.printStackTrace(); - } finally { - closeResources(bufferedReader, fileWriter); - } - } + if (primarySkillType != null && SkillTools.isChildSkill(primarySkillType)) { + logger.severe( + "A plugin hooking into mcMMO is being naughty with our database commands, update all plugins that hook into mcMMO and contact their devs!"); + throw new InvalidSkillException( + "A plugin hooking into mcMMO that you are using is attempting to read leaderboard skills for child skills, child skills do not have leaderboards! This is NOT an mcMMO error!"); } - if (flagsFound == null || flagsFound.isEmpty()) { - return null; - } else { - return flagsFound; + updateLeaderboards(); + + List statsList = + (primarySkillType == null) ? powerLevels : leaderboardMap.get(primarySkillType); + + if (statsList == null) { + return List.of(); } + + int fromIndex = (Math.max(pageNumber, 1) - 1) * statsPerPage; + int start = Math.min(fromIndex, statsList.size()); + int end = Math.min(fromIndex + statsPerPage, statsList.size()); + + return statsList.subList(start, end); } - private void closeResources(BufferedReader bufferedReader, FileWriter fileWriter) { - if (bufferedReader != null) { - try { - bufferedReader.close(); - } catch (IOException e) { - e.printStackTrace(); - } + public @NotNull HashMap readRank(String playerName) { + updateLeaderboards(); + + HashMap skills = new HashMap<>(); + + for (PrimarySkillType skill : SkillTools.NON_CHILD_SKILLS) { + skills.put(skill, getPlayerRank(playerName, leaderboardMap.get(skill))); } - if (fileWriter != null) { - try { - fileWriter.close(); - } catch (IOException e) { - e.printStackTrace(); - } - } + skills.put(null, getPlayerRank(playerName, powerLevels)); + return skills; } private Integer getPlayerRank(String playerName, List statsList) { @@ -1257,15 +951,12 @@ public final class FlatFileDatabaseManager implements DatabaseManager { } int currentPos = 1; - for (PlayerStat stat : statsList) { if (stat.playerName().equalsIgnoreCase(playerName)) { return currentPos; } - currentPos++; } - return null; } @@ -1274,86 +965,118 @@ public final class FlatFileDatabaseManager implements DatabaseManager { return statValue; } + // ------------------------------------------------------------------------ + // DB file creation / validation + // ------------------------------------------------------------------------ + + private void initEmptyDB() { + synchronized (fileWritingLock) { + try (BufferedWriter bufferedWriter = + new BufferedWriter(new FileWriter(usersFilePath, true))) { + + DateTimeFormatter dateTimeFormatter = + DateTimeFormatter.ofPattern("MM/dd/yyyy HH:mm"); + LocalDateTime localDateTime = LocalDateTime.now(); + + bufferedWriter.append("# mcMMO Database created on ") + .append(localDateTime.format(dateTimeFormatter)) + .append(LINE_ENDING); + } catch (IOException e) { + logger.log(Level.SEVERE, + "Unexpected Exception while initializing " + usersFilePath, e); + } + } + } + + public @Nullable List checkFileHealthAndStructure() { + ArrayList flagsFound = null; + + LogUtils.debug(logger, "(" + usersFile.getPath() + ") Validating database file.."); + + if (!usersFile.exists()) { + return null; + } + + synchronized (fileWritingLock) { + FlatFileDataProcessor dataProcessor = new FlatFileDataProcessor(logger); + + String dbCommentDate = null; + + try (BufferedReader bufferedReader = newBufferedReader()) { + String currentLine; + + while ((currentLine = bufferedReader.readLine()) != null) { + if (currentLine.startsWith("#") && dbCommentDate == null) { + dbCommentDate = currentLine; + continue; + } + + if (currentLine.isEmpty()) { + continue; + } + + dataProcessor.processData(currentLine); + } + } catch (IOException e) { + logger.log(Level.SEVERE, + "Unexpected Exception while validating " + usersFilePath, e); + } + + if (!dataProcessor.getFlatFileDataFlags().isEmpty()) { + flagsFound = new ArrayList<>(dataProcessor.getFlatFileDataFlags()); + logger.info("Updating FlatFile Database..."); + + try (FileWriter fileWriter = new FileWriter(usersFilePath)) { + if (dbCommentDate != null) { + fileWriter.write(dbCommentDate + LINE_ENDING); + } + fileWriter.write(dataProcessor.processDataForSave().toString()); + } catch (IOException e) { + logger.log(Level.SEVERE, + "Unexpected Exception while writing " + usersFilePath, e); + } + } + } + + if (flagsFound == null || flagsFound.isEmpty()) { + return null; + } + + return flagsFound; + } + + // ------------------------------------------------------------------------ + // Line parsing helpers + // ------------------------------------------------------------------------ + private PlayerProfile loadFromLine(@NotNull String[] character) { - Map skills = getSkillMapFromLine(character); // Skill levels - Map skillsXp = new EnumMap<>( - PrimarySkillType.class); // Skill & XP - Map skillsDATS = new EnumMap<>( - SuperAbilityType.class); // Ability & Cooldown + Map skills = getSkillMapFromLine(character); + Map skillsXp = new EnumMap<>(PrimarySkillType.class); + Map skillsDATS = new EnumMap<>(SuperAbilityType.class); Map uniquePlayerDataMap = new EnumMap<>(UniqueDataType.class); - int scoreboardTipsShown; - long lastLogin; String username = character[USERNAME_INDEX]; - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.TAMING, EXP_TAMING, - username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.MINING, EXP_MINING, - username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.REPAIR, EXP_REPAIR, - username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.WOODCUTTING, - EXP_WOODCUTTING, username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.UNARMED, - EXP_UNARMED, username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.HERBALISM, - EXP_HERBALISM, username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.EXCAVATION, - EXP_EXCAVATION, username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.ARCHERY, - EXP_ARCHERY, username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.SWORDS, EXP_SWORDS, - username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.AXES, EXP_AXES, - username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.ACROBATICS, - EXP_ACROBATICS, username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.FISHING, - EXP_FISHING, username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.ALCHEMY, - EXP_ALCHEMY, username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.CROSSBOWS, - EXP_CROSSBOWS, username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.TRIDENTS, - EXP_TRIDENTS, username); - tryLoadSkillFloatValuesFromRawData(skillsXp, character, PrimarySkillType.MACES, EXP_MACES, - username); - - // Taming - Unused - tryLoadSkillCooldownFromRawData(skillsDATS, character, SuperAbilityType.SUPER_BREAKER, - COOLDOWN_SUPER_BREAKER, username); - // Repair - Unused - tryLoadSkillCooldownFromRawData(skillsDATS, character, SuperAbilityType.TREE_FELLER, - COOLDOWN_TREE_FELLER, username); - tryLoadSkillCooldownFromRawData(skillsDATS, character, SuperAbilityType.BERSERK, - COOLDOWN_BERSERK, username); - tryLoadSkillCooldownFromRawData(skillsDATS, character, SuperAbilityType.GREEN_TERRA, - COOLDOWN_GREEN_TERRA, username); - tryLoadSkillCooldownFromRawData(skillsDATS, character, SuperAbilityType.GIGA_DRILL_BREAKER, - COOLDOWN_GIGA_DRILL_BREAKER, username); - tryLoadSkillCooldownFromRawData(skillsDATS, character, SuperAbilityType.EXPLOSIVE_SHOT, - COOLDOWN_ARCHERY, username); - tryLoadSkillCooldownFromRawData(skillsDATS, character, SuperAbilityType.SERRATED_STRIKES, - COOLDOWN_SERRATED_STRIKES, username); - tryLoadSkillCooldownFromRawData(skillsDATS, character, SuperAbilityType.SKULL_SPLITTER, - COOLDOWN_SKULL_SPLITTER, username); - // Acrobatics - Unused - tryLoadSkillCooldownFromRawData(skillsDATS, character, SuperAbilityType.BLAST_MINING, - COOLDOWN_BLAST_MINING, username); - tryLoadSkillCooldownFromRawData(skillsDATS, character, SuperAbilityType.SUPER_SHOTGUN, - COOLDOWN_SUPER_SHOTGUN, username); - tryLoadSkillCooldownFromRawData(skillsDATS, character, - SuperAbilityType.TRIDENTS_SUPER_ABILITY, COOLDOWN_TRIDENTS, username); - tryLoadSkillCooldownFromRawData(skillsDATS, character, SuperAbilityType.MACES_SUPER_ABILITY, - COOLDOWN_MACES, username); - - UUID uuid; - try { - uuid = UUID.fromString(character[UUID_INDEX]); - } catch (Exception e) { - uuid = null; + // XP values + for (SkillIndex skillIndex : SKILL_XP_INDICES) { + tryLoadSkillFloatValuesFromRawData( + skillsXp, + character, + skillIndex.type(), + skillIndex.index(), + username + ); } + // Ability cooldowns + for (AbilityIndex abilityIndex : ABILITY_COOLDOWN_INDICES) { + tryLoadSkillCooldownFromRawData(skillsDATS, character, abilityIndex.type(), + abilityIndex.index(), username); + } + + UUID uuid = parseUuidOrNull(character[UUID_INDEX]); + + int scoreboardTipsShown; try { scoreboardTipsShown = Integer.parseInt(character[SCOREBOARD_TIPS]); } catch (Exception e) { @@ -1367,6 +1090,7 @@ public final class FlatFileDatabaseManager implements DatabaseManager { uniquePlayerDataMap.put(UniqueDataType.CHIMAERA_WING_DATS, 0); } + long lastLogin; try { lastLogin = Long.parseLong(character[OVERHAUL_LAST_LOGIN]); } catch (Exception e) { @@ -1383,8 +1107,6 @@ public final class FlatFileDatabaseManager implements DatabaseManager { try { cooldownMap.put(superAbilityType, Integer.valueOf(splitData[index])); } catch (IndexOutOfBoundsException e) { - // TODO: Add debug message - // set to 0 when data not found cooldownMap.put(superAbilityType, 0); } catch (NumberFormatException e) { throw new NumberFormatException( @@ -1401,10 +1123,9 @@ public final class FlatFileDatabaseManager implements DatabaseManager { skillMap.put(primarySkillType, valueFromString); } catch (NumberFormatException e) { skillMap.put(primarySkillType, 0F); - logger.severe( - "Data corruption when trying to load the value for skill " + primarySkillType - + " for player named " + userName + " setting value to zero"); - e.printStackTrace(); + logger.severe("Data corruption when trying to load the value for skill " + + primarySkillType + " for player named " + userName + " setting value to zero"); + logger.log(Level.SEVERE, e.getMessage(), e); } } @@ -1415,60 +1136,99 @@ public final class FlatFileDatabaseManager implements DatabaseManager { int valueFromString = Integer.parseInt(character[index]); skillMap.put(primarySkillType, valueFromString); } catch (ArrayIndexOutOfBoundsException e) { - // TODO: Add debug message - // set to 0 when data not found skillMap.put(primarySkillType, 0); } catch (NumberFormatException e) { skillMap.put(primarySkillType, 0); - logger.severe( - "Data corruption when trying to load the value for skill " + primarySkillType - + " for player named " + userName + " setting value to zero"); - e.printStackTrace(); + logger.severe("Data corruption when trying to load the value for skill " + + primarySkillType + " for player named " + userName + " setting value to zero"); + logger.log(Level.SEVERE, e.getMessage(), e); } } private @NotNull Map getSkillMapFromLine( @NotNull String[] character) { - EnumMap skills = new EnumMap<>( - PrimarySkillType.class); // Skill & Level + + EnumMap skills = new EnumMap<>(PrimarySkillType.class); + String username = character[USERNAME_INDEX]; - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.ACROBATICS, - SKILLS_ACROBATICS, username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.TAMING, SKILLS_TAMING, - username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.MINING, SKILLS_MINING, - username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.REPAIR, SKILLS_REPAIR, - username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.WOODCUTTING, - SKILLS_WOODCUTTING, username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.UNARMED, - SKILLS_UNARMED, username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.HERBALISM, - SKILLS_HERBALISM, username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.EXCAVATION, - SKILLS_EXCAVATION, username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.ARCHERY, - SKILLS_ARCHERY, username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.SWORDS, SKILLS_SWORDS, - username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.AXES, SKILLS_AXES, - username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.FISHING, - SKILLS_FISHING, username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.ALCHEMY, - SKILLS_ALCHEMY, username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.CROSSBOWS, - SKILLS_CROSSBOWS, username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.TRIDENTS, - SKILLS_TRIDENTS, username); - tryLoadSkillIntValuesFromRawData(skills, character, PrimarySkillType.MACES, SKILLS_MACES, - username); + for (SkillIndex skillIndex : SKILL_LEVEL_INDICES) { + tryLoadSkillIntValuesFromRawData(skills, character, skillIndex.type(), + skillIndex.index(), username); + } return skills; } + // ------------------------------------------------------------------------ + // Type / IO helpers + // ------------------------------------------------------------------------ + + private @NotNull BufferedReader newBufferedReader() throws IOException { + return new BufferedReader(new FileReader(usersFilePath)); + } + + private void writeStringToFileSafely(String contents) { + try (FileWriter out = new FileWriter(usersFilePath)) { + out.write(contents); + } catch (IOException e) { + logger.log(Level.SEVERE, + "Unexpected Exception while writing " + usersFilePath, e); + } + } + + private void withUsersFileLines(@NotNull Consumer lineConsumer) { + synchronized (fileWritingLock) { + try (BufferedReader in = newBufferedReader()) { + String line; + while ((line = in.readLine()) != null) { + lineConsumer.accept(line); + } + } catch (IOException e) { + logger.log(Level.SEVERE, + "Unexpected Exception while reading " + usersFilePath, e); + } + } + } + + private void rewriteUsersFile(@NotNull Function lineMapper) { + synchronized (fileWritingLock) { + StringBuilder writer = new StringBuilder(); + + try (BufferedReader in = newBufferedReader()) { + String line; + while ((line = in.readLine()) != null) { + String mapped = lineMapper.apply(line); + if (mapped != null) { + writer.append(mapped).append(LINE_ENDING); + } + } + } catch (IOException e) { + logger.severe("Exception while reading " + usersFilePath + + " (Are you sure you formatted it correctly?)" + e); + } + + writeStringToFileSafely(writer.toString()); + } + } + + private @Nullable UUID parseUuidOrNull(@Nullable String uuidString) { + if (uuidString == null || uuidString.isEmpty() + || "NULL".equalsIgnoreCase(uuidString)) { + return null; + } + + try { + return UUID.fromString(uuidString); + } catch (IllegalArgumentException e) { + return null; + } + } + + // ------------------------------------------------------------------------ + // DatabaseManager API + // ------------------------------------------------------------------------ + public DatabaseType getDatabaseType() { return DatabaseType.FLATFILE; } @@ -1479,5 +1239,59 @@ public final class FlatFileDatabaseManager implements DatabaseManager { @Override public void onDisable() { + // nothing to do } + + private void appendInt(@NotNull Appendable out, int value) throws IOException { + out.append(Integer.toString(value)).append(':'); + } + + private void appendLong(@NotNull Appendable out, long value) throws IOException { + out.append(Long.toString(value)).append(':'); + } + + private void appendString(@NotNull Appendable out, @NotNull String value) throws IOException { + out.append(value).append(':'); + } + + private void appendIgnored(@NotNull Appendable out) throws IOException { + out.append(IGNORED).append(':'); + } + + private record FlatFileRow(String rawLine, String[] fields, String username, + @Nullable UUID uuid) { + + static @Nullable FlatFileRow parse(@NotNull String line, + @NotNull Logger logger, + @NotNull String usersFilePath) { + String trimmed = line.trim(); + + // Skip comments and empty lines + if (trimmed.isEmpty() || trimmed.startsWith("#")) { + return null; + } + + String[] data = trimmed.split(":"); + if (data.length <= USERNAME_INDEX) { + // Not enough data to contain a username; treat as malformed and skip + logger.warning("Skipping malformed line in " + usersFilePath + ": " + trimmed); + return null; + } + + String username = data[USERNAME_INDEX]; + UUID uuid = null; + if (data.length > UUID_INDEX) { + try { + String uuidString = data[UUID_INDEX]; + if (!uuidString.isEmpty() && !"NULL".equalsIgnoreCase(uuidString)) { + uuid = UUID.fromString(uuidString); + } + } catch (IllegalArgumentException ignored) { + // Malformed UUID; we keep uuid = null + } + } + + return new FlatFileRow(line, data, username, uuid); + } + } } diff --git a/src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java b/src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java index 50866edb1..e6e5d7354 100644 --- a/src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java +++ b/src/main/java/com/gmail/nossr50/database/SQLDatabaseManager.java @@ -14,22 +14,6 @@ import com.gmail.nossr50.runnables.database.UUIDUpdateAsyncTask; import com.gmail.nossr50.util.LogUtils; import com.gmail.nossr50.util.Misc; import com.gmail.nossr50.util.skills.SkillTools; -import java.sql.Connection; -import java.sql.DatabaseMetaData; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.ResultSetMetaData; -import java.sql.SQLException; -import java.sql.Statement; -import java.util.ArrayList; -import java.util.EnumMap; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.locks.ReentrantLock; -import java.util.logging.Logger; import org.apache.tomcat.jdbc.pool.DataSource; import org.apache.tomcat.jdbc.pool.PoolProperties; import org.bukkit.OfflinePlayer; @@ -37,181 +21,222 @@ import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.sql.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; +import java.util.logging.Level; +import java.util.logging.Logger; + public final class SQLDatabaseManager implements DatabaseManager { - private static final String ALL_QUERY_VERSION = "total"; + + // --------------------------------------------------------------------- + // Constants + // --------------------------------------------------------------------- + public static final String MOBHEALTHBAR_VARCHAR = "VARCHAR(50)"; public static final String UUID_VARCHAR = "VARCHAR(36)"; public static final String USER_VARCHAR = "VARCHAR(40)"; public static final int CHILD_SKILLS_SIZE = 2; public static final String LEGACY_DRIVER_PATH = "com.mysql.jdbc.Driver"; - public static final int MAGIC_NUMBER = 44; + private static final String ALL_QUERY_VERSION = "total"; + private static final String INVALID_OLD_USERNAME = "_INVALID_OLD_USERNAME_"; + + /** + * utf8mb4 is the "real" UTF-8, unlike MySQL's legacy "utf8". + */ + private static final String CHARSET_SQL = "utf8mb4"; + + private static final PrimarySkillType[] PERSISTED_SKILLS = { + PrimarySkillType.TAMING, + PrimarySkillType.MINING, + PrimarySkillType.REPAIR, + PrimarySkillType.WOODCUTTING, + PrimarySkillType.UNARMED, + PrimarySkillType.HERBALISM, + PrimarySkillType.EXCAVATION, + PrimarySkillType.ARCHERY, + PrimarySkillType.SWORDS, + PrimarySkillType.AXES, + PrimarySkillType.ACROBATICS, + PrimarySkillType.FISHING, + PrimarySkillType.ALCHEMY, + PrimarySkillType.CROSSBOWS, + PrimarySkillType.TRIDENTS, + PrimarySkillType.MACES, + PrimarySkillType.SPEARS + }; + + // --------------------------------------------------------------------- + // Instance fields + // --------------------------------------------------------------------- + private final String tablePrefix = mcMMO.p.getGeneralConfig().getMySQLTablePrefix(); + private final Logger logger; - private final Map cachedUserIDs = new HashMap<>(); - + /** + * Cache of user IDs by UUID. Concurrent for cross-thread DB usage. + */ + private final Map cachedUserIDs = new ConcurrentHashMap<>(); + private final ReentrantLock massUpdateLock = new ReentrantLock(); private DataSource miscPool; private DataSource loadPool; private DataSource savePool; - private boolean debug = false; - - private final ReentrantLock massUpdateLock = new ReentrantLock(); - - private final String CHARSET_SQL = "utf8mb4"; //This is compliant with UTF-8 while "utf8" is not, confusing but this is how it is. - private final Logger logger; - private final boolean h2; + // --------------------------------------------------------------------- + // Construction / pool setup + // --------------------------------------------------------------------- SQLDatabaseManager(Logger logger, String driverPath) { - this(logger, driverPath, false); - } + this.logger = Objects.requireNonNull(logger, "logger"); - SQLDatabaseManager(Logger logger, String driverPath, boolean h2) { - this.logger = logger; - this.h2 = h2; - String connectionString = getConnectionString(h2); + final String connectionString = buildConnectionStringWithOptions(); - if (!h2 && mcMMO.p.getGeneralConfig().getMySQLPublicKeyRetrieval()) { - connectionString += - "&allowPublicKeyRetrieval=true"; + if (!loadDriver(driverPath)) { + logger.severe( + "Neither MySQL driver was found; aborting SQLDatabaseManager initialization."); + return; } - try { - // Force driver to load if not yet loaded - Class.forName(driverPath); - } catch (ClassNotFoundException e) { - try { - driverPath = LEGACY_DRIVER_PATH; //fall on deprecated path if new path isn't found - Class.forName(driverPath); - } catch (ClassNotFoundException ex) { - e.printStackTrace(); - ex.printStackTrace(); - logger.severe("Neither driver found"); - return; - } - //throw e; // aborts onEnable() Riking if you want to do this, fully implement it. - } - - debug = mcMMO.p.getGeneralConfig().getMySQLDebug(); - - PoolProperties poolProperties = new PoolProperties(); - poolProperties.setDriverClassName(driverPath); - poolProperties.setUrl(connectionString); - poolProperties.setUsername(mcMMO.p.getGeneralConfig().getMySQLUserName()); - poolProperties.setPassword(mcMMO.p.getGeneralConfig().getMySQLUserPassword()); - poolProperties.setMaxIdle( - mcMMO.p.getGeneralConfig().getMySQLMaxPoolSize(PoolIdentifier.MISC)); - poolProperties.setMaxActive( - mcMMO.p.getGeneralConfig().getMySQLMaxConnections(PoolIdentifier.MISC)); - poolProperties.setInitialSize(0); - poolProperties.setMaxWait(-1); - poolProperties.setRemoveAbandoned(true); - poolProperties.setRemoveAbandonedTimeout(60); - poolProperties.setTestOnBorrow(true); - poolProperties.setValidationQuery("SELECT 1"); - poolProperties.setValidationInterval(30000); - miscPool = new DataSource(poolProperties); - poolProperties = new PoolProperties(); - poolProperties.setDriverClassName(driverPath); - poolProperties.setUrl(connectionString); - poolProperties.setUsername(mcMMO.p.getGeneralConfig().getMySQLUserName()); - poolProperties.setPassword(mcMMO.p.getGeneralConfig().getMySQLUserPassword()); - poolProperties.setInitialSize(0); - poolProperties.setMaxIdle( - mcMMO.p.getGeneralConfig().getMySQLMaxPoolSize(PoolIdentifier.SAVE)); - poolProperties.setMaxActive( - mcMMO.p.getGeneralConfig().getMySQLMaxConnections(PoolIdentifier.SAVE)); - poolProperties.setMaxWait(-1); - poolProperties.setRemoveAbandoned(true); - poolProperties.setRemoveAbandonedTimeout(60); - poolProperties.setTestOnBorrow(true); - poolProperties.setValidationQuery("SELECT 1"); - poolProperties.setValidationInterval(30000); - savePool = new DataSource(poolProperties); - poolProperties = new PoolProperties(); - poolProperties.setDriverClassName(driverPath); - poolProperties.setUrl(connectionString); - poolProperties.setUsername(mcMMO.p.getGeneralConfig().getMySQLUserName()); - poolProperties.setPassword(mcMMO.p.getGeneralConfig().getMySQLUserPassword()); - poolProperties.setInitialSize(0); - poolProperties.setMaxIdle( - mcMMO.p.getGeneralConfig().getMySQLMaxPoolSize(PoolIdentifier.LOAD)); - poolProperties.setMaxActive( - mcMMO.p.getGeneralConfig().getMySQLMaxConnections(PoolIdentifier.LOAD)); - poolProperties.setMaxWait(-1); - poolProperties.setRemoveAbandoned(true); - poolProperties.setRemoveAbandonedTimeout(60); - poolProperties.setTestOnBorrow(true); - poolProperties.setValidationQuery("SELECT 1"); - poolProperties.setValidationInterval(30000); - loadPool = new DataSource(poolProperties); + // Set up pools + final var config = mcMMO.p.getGeneralConfig(); + this.miscPool = createDataSource( + driverPath, + connectionString, + config.getMySQLMaxPoolSize(PoolIdentifier.MISC), + config.getMySQLMaxConnections(PoolIdentifier.MISC) + ); + this.savePool = createDataSource( + driverPath, + connectionString, + config.getMySQLMaxPoolSize(PoolIdentifier.SAVE), + config.getMySQLMaxConnections(PoolIdentifier.SAVE) + ); + this.loadPool = createDataSource( + driverPath, + connectionString, + config.getMySQLMaxPoolSize(PoolIdentifier.LOAD), + config.getMySQLMaxConnections(PoolIdentifier.LOAD) + ); checkStructure(); } @NotNull - private static String getConnectionString(boolean h2) { - if (h2) { - return "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL"; - } - - String connectionString = "jdbc:mysql://" + mcMMO.p.getGeneralConfig().getMySQLServerName() - + ":" + mcMMO.p.getGeneralConfig().getMySQLServerPort() + "/" - + mcMMO.p.getGeneralConfig().getMySQLDatabaseName(); + private static String getConnectionString() { + final var general = mcMMO.p.getGeneralConfig(); + String connectionString = "jdbc:mysql://" + general.getMySQLServerName() + + ":" + general.getMySQLServerPort() + "/" + + general.getMySQLDatabaseName(); + // Temporary hack for 1.17 + SSL support (legacy path kept intact) if (!mcMMO.getCompatibilityManager().getMinecraftGameVersion().isAtLeast(1, 17, 0) - //Temporary hack for SQL and 1.17 support - && mcMMO.p.getGeneralConfig().getMySQLSSL()) { - connectionString += - "?verifyServerCertificate=false" + - "&useSSL=true" + - "&requireSSL=true"; + && general.getMySQLSSL()) { + connectionString += "?verifyServerCertificate=false&useSSL=true&requireSSL=true"; } else { - connectionString += - "?useSSL=false"; + connectionString += "?useSSL=false"; } return connectionString; } - // TODO: unit tests + @NotNull + private String buildConnectionStringWithOptions() { + String connectionString = getConnectionString(); + + if (mcMMO.p.getGeneralConfig().getMySQLPublicKeyRetrieval()) { + connectionString += "&allowPublicKeyRetrieval=true"; + } + return connectionString; + } + + private boolean loadDriver(String driverPath) { + try { + Class.forName(driverPath); + return true; + } catch (ClassNotFoundException primary) { + try { + Class.forName(LEGACY_DRIVER_PATH); + logger.info("Primary driver not found; using legacy MySQL driver: " + + LEGACY_DRIVER_PATH); + return true; + } catch (ClassNotFoundException legacy) { + logger.log(Level.SEVERE, "Initial driver path load failed", primary); + logger.log(Level.SEVERE, "Legacy driver path load failed", legacy); + return false; + } + } + } + + private DataSource createDataSource(String driverPath, + String connectionString, + int maxIdle, + int maxActive) { + PoolProperties poolProps = new PoolProperties(); + poolProps.setDriverClassName(driverPath); + poolProps.setUrl(connectionString); + poolProps.setUsername(mcMMO.p.getGeneralConfig().getMySQLUserName()); + poolProps.setPassword(mcMMO.p.getGeneralConfig().getMySQLUserPassword()); + + poolProps.setInitialSize(0); + poolProps.setMaxIdle(maxIdle); + poolProps.setMaxActive(maxActive); + poolProps.setMaxWait(-1); + poolProps.setRemoveAbandoned(true); + poolProps.setRemoveAbandonedTimeout(60); + poolProps.setTestOnBorrow(true); + poolProps.setValidationQuery("SELECT 1"); + poolProps.setValidationInterval(30_000); + + return new DataSource(poolProps); + } + + // --------------------------------------------------------------------- + // Public operations + // --------------------------------------------------------------------- + public int purgePowerlessUsers() { massUpdateLock.lock(); logger.info("Purging powerless users..."); - Connection connection = null; - Statement statement = null; int purged = 0; - try { - connection = getConnection(PoolIdentifier.MISC); - statement = connection.createStatement(); + try (Connection connection = getConnection(PoolIdentifier.MISC); + Statement statement = connection.createStatement()) { - purged = statement.executeUpdate("DELETE FROM " + tablePrefix + "skills WHERE " - + "taming = 0 AND mining = 0 AND woodcutting = 0 AND repair = 0 " - + "AND unarmed = 0 AND herbalism = 0 AND excavation = 0 AND " - + "archery = 0 AND swords = 0 AND axes = 0 AND acrobatics = 0 " - + "AND fishing = 0 AND alchemy = 0 AND crossbows = 0 AND tridents = 0 AND maces = 0;"); + purged = statement.executeUpdate( + "DELETE FROM " + tablePrefix + "skills WHERE " + + "taming = 0 AND mining = 0 AND woodcutting = 0 AND repair = 0 " + + "AND unarmed = 0 AND herbalism = 0 AND excavation = 0 AND " + + "archery = 0 AND swords = 0 AND axes = 0 AND acrobatics = 0 " + + "AND fishing = 0 AND alchemy = 0 AND crossbows = 0 AND tridents = 0 " + + "AND maces = 0 AND spears = 0;" + ); statement.executeUpdate( "DELETE FROM `" + tablePrefix + "experience` WHERE NOT EXISTS (SELECT * FROM `" + tablePrefix + "skills` `s` WHERE `" + tablePrefix - + "experience`.`user_id` = `s`.`user_id`)"); + + "experience`.`user_id` = `s`.`user_id`)" + ); statement.executeUpdate( "DELETE FROM `" + tablePrefix + "huds` WHERE NOT EXISTS (SELECT * FROM `" + tablePrefix + "skills` `s` WHERE `" + tablePrefix - + "huds`.`user_id` = `s`.`user_id`)"); + + "huds`.`user_id` = `s`.`user_id`)" + ); statement.executeUpdate( "DELETE FROM `" + tablePrefix + "cooldowns` WHERE NOT EXISTS (SELECT * FROM `" + tablePrefix + "skills` `s` WHERE `" + tablePrefix - + "cooldowns`.`user_id` = `s`.`user_id`)"); + + "cooldowns`.`user_id` = `s`.`user_id`)" + ); statement.executeUpdate( "DELETE FROM `" + tablePrefix + "users` WHERE NOT EXISTS (SELECT * FROM `" + tablePrefix + "skills` `s` WHERE `" + tablePrefix - + "users`.`id` = `s`.`user_id`)"); + + "users`.`id` = `s`.`user_id`)" + ); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { - tryClose(statement); - tryClose(connection); massUpdateLock.unlock(); } @@ -221,16 +246,13 @@ public final class SQLDatabaseManager implements DatabaseManager { public void purgeOldUsers() { massUpdateLock.lock(); - logger.info("Purging inactive users older than " + (mcMMO.p.getPurgeTime() / 2630000000L) - + " months..."); + long months = mcMMO.p.getPurgeTime() / 2_630_000_000L; + logger.info("Purging inactive users older than " + months + " months..."); - Connection connection = null; - Statement statement = null; int purged = 0; - try { - connection = getConnection(PoolIdentifier.MISC); - statement = connection.createStatement(); + try (Connection connection = getConnection(PoolIdentifier.MISC); + Statement statement = connection.createStatement()) { purged = statement.executeUpdate( "DELETE FROM u, e, h, s, c USING " + tablePrefix + "users u " + @@ -239,12 +261,11 @@ public final class SQLDatabaseManager implements DatabaseManager { "JOIN " + tablePrefix + "skills s ON (u.id = s.user_id) " + "JOIN " + tablePrefix + "cooldowns c ON (u.id = c.user_id) " + "WHERE ((UNIX_TIMESTAMP() - lastlogin) > " + mcMMO.p.getPurgeTime() - + ")"); + + ")" + ); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { - tryClose(statement); - tryClose(connection); massUpdateLock.unlock(); } @@ -253,34 +274,28 @@ public final class SQLDatabaseManager implements DatabaseManager { public boolean removeUser(String playerName, UUID uuid) { boolean success = false; - Connection connection = null; - PreparedStatement statement = null; - try { - connection = getConnection(PoolIdentifier.MISC); - statement = connection.prepareStatement("DELETE FROM u, e, h, s, c " + - "USING " + tablePrefix + "users u " + - "JOIN " + tablePrefix + "experience e ON (u.id = e.user_id) " + - "JOIN " + tablePrefix + "huds h ON (u.id = h.user_id) " + - "JOIN " + tablePrefix + "skills s ON (u.id = s.user_id) " + - "JOIN " + tablePrefix + "cooldowns c ON (u.id = c.user_id) " + - "WHERE u.`user` = ?"); + String sql = "DELETE FROM u, e, h, s, c " + + "USING " + tablePrefix + "users u " + + "JOIN " + tablePrefix + "experience e ON (u.id = e.user_id) " + + "JOIN " + tablePrefix + "huds h ON (u.id = h.user_id) " + + "JOIN " + tablePrefix + "skills s ON (u.id = s.user_id) " + + "JOIN " + tablePrefix + "cooldowns c ON (u.id = c.user_id) " + + "WHERE u.`user` = ?"; + + try (Connection connection = getConnection(PoolIdentifier.MISC); + PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, playerName); - success = statement.executeUpdate() != 0; } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(statement); - tryClose(connection); + logSQLException(ex); } if (success) { if (uuid != null) { cleanupUser(uuid); } - Misc.profileCleanup(playerName); } @@ -291,376 +306,504 @@ public final class SQLDatabaseManager implements DatabaseManager { cachedUserIDs.remove(uuid); } + @Override public boolean saveUser(PlayerProfile profile) { - boolean success = true; - PreparedStatement statement = null; - Connection connection = null; + final String playerName = profile.getPlayerName(); + final UUID uuid = profile.getUniqueId(); - try { - connection = getConnection(PoolIdentifier.SAVE); + try (Connection connection = getConnection(PoolIdentifier.SAVE)) { + boolean originalAutoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); - int id = getUserID(connection, profile.getPlayerName(), profile.getUniqueId()); + try { + int userId = getUserID(connection, playerName, uuid); + if (userId == -1) { + userId = newUser(connection, playerName, uuid); + if (userId == -1) { + logger.severe("Failed to create new account for " + playerName); + connection.rollback(); + return false; + } + } - if (id == -1) { - id = newUser(connection, profile.getPlayerName(), profile.getUniqueId()); - if (id == -1) { - logger.severe("Failed to create new account for " + profile.getPlayerName()); + if (!updateLastLogin(connection, userId, playerName) + || !updateSkills(connection, userId, profile, playerName) + || !updateExperience(connection, userId, profile, playerName) + || !updateCooldowns(connection, userId, profile, playerName) + || !updateHudSettings(connection, userId, profile, playerName)) { + connection.rollback(); return false; } - } - statement = connection.prepareStatement("UPDATE " + tablePrefix - + "users SET lastlogin = UNIX_TIMESTAMP() WHERE id = ?"); - statement.setInt(1, id); - success &= (statement.executeUpdate() != 0); - statement.close(); - if (!success) { - logger.severe("Failed to update last login for " + profile.getPlayerName()); + connection.commit(); + connection.setAutoCommit(originalAutoCommit); + return true; + } catch (SQLException e) { + connection.rollback(); + logSQLException(e); + return false; + } finally { + // Best-effort restore + try { + connection.setAutoCommit(true); + } catch (SQLException ignored) { + // ignore + } + } + } catch (SQLException ex) { + logSQLException(ex); + return false; + } + } + + // --------------------------------------------------------------------- + // Update helpers + // --------------------------------------------------------------------- + + private boolean updateLastLogin(Connection connection, int userId, String playerName) { + String sql = + "UPDATE " + tablePrefix + "users SET lastlogin = UNIX_TIMESTAMP() WHERE id = ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setInt(1, userId); + if (stmt.executeUpdate() == 0) { + logger.severe("Failed to update last login for " + playerName); return false; } + return true; + } catch (SQLException ex) { + logSQLException(ex); + return false; + } + } + + private boolean updateSkills(Connection connection, int userId, PlayerProfile profile, + String playerName) { + String sql = "UPDATE " + tablePrefix + "skills SET " + + " taming = ?, mining = ?, repair = ?, woodcutting = ?" + + ", unarmed = ?, herbalism = ?, excavation = ?" + + ", archery = ?, swords = ?, axes = ?, acrobatics = ?" + + ", fishing = ?, alchemy = ?, crossbows = ?, tridents = ?, maces = ?, spears = ?, total = ?" + + " WHERE user_id = ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + int i = 1; + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.TAMING)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.MINING)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.REPAIR)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.WOODCUTTING)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.UNARMED)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.HERBALISM)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.EXCAVATION)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.ARCHERY)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.SWORDS)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.AXES)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.ACROBATICS)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.FISHING)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.ALCHEMY)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.CROSSBOWS)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.TRIDENTS)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.MACES)); + stmt.setInt(i++, profile.getSkillLevel(PrimarySkillType.SPEARS)); - statement = connection.prepareStatement("UPDATE " + tablePrefix + "skills SET " - + " taming = ?, mining = ?, repair = ?, woodcutting = ?" - + ", unarmed = ?, herbalism = ?, excavation = ?" - + ", archery = ?, swords = ?, axes = ?, acrobatics = ?" - + ", fishing = ?, alchemy = ?, crossbows = ?, tridents = ?, maces = ?, total = ? WHERE user_id = ?"); - statement.setInt(1, profile.getSkillLevel(PrimarySkillType.TAMING)); - statement.setInt(2, profile.getSkillLevel(PrimarySkillType.MINING)); - statement.setInt(3, profile.getSkillLevel(PrimarySkillType.REPAIR)); - statement.setInt(4, profile.getSkillLevel(PrimarySkillType.WOODCUTTING)); - statement.setInt(5, profile.getSkillLevel(PrimarySkillType.UNARMED)); - statement.setInt(6, profile.getSkillLevel(PrimarySkillType.HERBALISM)); - statement.setInt(7, profile.getSkillLevel(PrimarySkillType.EXCAVATION)); - statement.setInt(8, profile.getSkillLevel(PrimarySkillType.ARCHERY)); - statement.setInt(9, profile.getSkillLevel(PrimarySkillType.SWORDS)); - statement.setInt(10, profile.getSkillLevel(PrimarySkillType.AXES)); - statement.setInt(11, profile.getSkillLevel(PrimarySkillType.ACROBATICS)); - statement.setInt(12, profile.getSkillLevel(PrimarySkillType.FISHING)); - statement.setInt(13, profile.getSkillLevel(PrimarySkillType.ALCHEMY)); - statement.setInt(14, profile.getSkillLevel(PrimarySkillType.CROSSBOWS)); - statement.setInt(15, profile.getSkillLevel(PrimarySkillType.TRIDENTS)); - statement.setInt(16, profile.getSkillLevel(PrimarySkillType.MACES)); int total = 0; for (PrimarySkillType primarySkillType : SkillTools.NON_CHILD_SKILLS) { total += profile.getSkillLevel(primarySkillType); } - statement.setInt(17, total); - statement.setInt(18, id); - success &= (statement.executeUpdate() != 0); - statement.close(); - if (!success) { - logger.severe("Failed to update skills for " + profile.getPlayerName()); - return false; - } + stmt.setInt(i++, total); + stmt.setInt(i, userId); - statement = connection.prepareStatement("UPDATE " + tablePrefix + "experience SET " - + " taming = ?, mining = ?, repair = ?, woodcutting = ?" - + ", unarmed = ?, herbalism = ?, excavation = ?" - + ", archery = ?, swords = ?, axes = ?, acrobatics = ?" - + ", fishing = ?, alchemy = ?, crossbows = ?, tridents = ?, maces = ? WHERE user_id = ?"); - statement.setInt(1, profile.getSkillXpLevel(PrimarySkillType.TAMING)); - statement.setInt(2, profile.getSkillXpLevel(PrimarySkillType.MINING)); - statement.setInt(3, profile.getSkillXpLevel(PrimarySkillType.REPAIR)); - statement.setInt(4, profile.getSkillXpLevel(PrimarySkillType.WOODCUTTING)); - statement.setInt(5, profile.getSkillXpLevel(PrimarySkillType.UNARMED)); - statement.setInt(6, profile.getSkillXpLevel(PrimarySkillType.HERBALISM)); - statement.setInt(7, profile.getSkillXpLevel(PrimarySkillType.EXCAVATION)); - statement.setInt(8, profile.getSkillXpLevel(PrimarySkillType.ARCHERY)); - statement.setInt(9, profile.getSkillXpLevel(PrimarySkillType.SWORDS)); - statement.setInt(10, profile.getSkillXpLevel(PrimarySkillType.AXES)); - statement.setInt(11, profile.getSkillXpLevel(PrimarySkillType.ACROBATICS)); - statement.setInt(12, profile.getSkillXpLevel(PrimarySkillType.FISHING)); - statement.setInt(13, profile.getSkillXpLevel(PrimarySkillType.ALCHEMY)); - statement.setInt(14, profile.getSkillXpLevel(PrimarySkillType.CROSSBOWS)); - statement.setInt(15, profile.getSkillXpLevel(PrimarySkillType.TRIDENTS)); - statement.setInt(16, profile.getSkillXpLevel(PrimarySkillType.MACES)); - statement.setInt(17, id); - success &= (statement.executeUpdate() != 0); - statement.close(); - if (!success) { - logger.severe("Failed to update experience for " + profile.getPlayerName()); - return false; - } - - statement = connection.prepareStatement("UPDATE " + tablePrefix + "cooldowns SET " - + " mining = ?, woodcutting = ?, unarmed = ?" - + ", herbalism = ?, excavation = ?, swords = ?" - + ", axes = ?, blast_mining = ?, chimaera_wing = ?, crossbows = ?" - + ", tridents = ?, maces = ? WHERE user_id = ?"); - statement.setLong(1, profile.getAbilityDATS(SuperAbilityType.SUPER_BREAKER)); - statement.setLong(2, profile.getAbilityDATS(SuperAbilityType.TREE_FELLER)); - statement.setLong(3, profile.getAbilityDATS(SuperAbilityType.BERSERK)); - statement.setLong(4, profile.getAbilityDATS(SuperAbilityType.GREEN_TERRA)); - statement.setLong(5, profile.getAbilityDATS(SuperAbilityType.GIGA_DRILL_BREAKER)); - statement.setLong(6, profile.getAbilityDATS(SuperAbilityType.SERRATED_STRIKES)); - statement.setLong(7, profile.getAbilityDATS(SuperAbilityType.SKULL_SPLITTER)); - statement.setLong(8, profile.getAbilityDATS(SuperAbilityType.BLAST_MINING)); - statement.setLong(9, profile.getUniqueData(UniqueDataType.CHIMAERA_WING_DATS)); - statement.setLong(10, profile.getAbilityDATS(SuperAbilityType.SUPER_SHOTGUN)); - statement.setLong(11, profile.getAbilityDATS(SuperAbilityType.TRIDENTS_SUPER_ABILITY)); - statement.setLong(12, profile.getAbilityDATS(SuperAbilityType.MACES_SUPER_ABILITY)); - statement.setInt(13, id); - success = (statement.executeUpdate() != 0); - statement.close(); - if (!success) { - logger.severe("Failed to update cooldowns for " + profile.getPlayerName()); - return false; - } - - statement = connection.prepareStatement("UPDATE " + tablePrefix - + "huds SET mobhealthbar = ?, scoreboardtips = ? WHERE user_id = ?"); - statement.setString(1, MobHealthbarType.HEARTS.name()); - statement.setInt(2, profile.getScoreboardTipsShown()); - statement.setInt(3, id); - success = (statement.executeUpdate() != 0); - statement.close(); - if (!success) { - logger.severe("Failed to update hud settings for " + profile.getPlayerName()); + if (stmt.executeUpdate() == 0) { + logger.severe("Failed to update skills for " + playerName); return false; } + return true; } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(statement); - tryClose(connection); + logSQLException(ex); + return false; } - - return success; } + private boolean updateExperience(Connection connection, int userId, PlayerProfile profile, + String playerName) { + String sql = "UPDATE " + tablePrefix + "experience SET " + + " taming = ?, mining = ?, repair = ?, woodcutting = ?" + + ", unarmed = ?, herbalism = ?, excavation = ?" + + ", archery = ?, swords = ?, axes = ?, acrobatics = ?" + + ", fishing = ?, alchemy = ?, crossbows = ?, tridents = ?, maces = ?, spears = ?" + + " WHERE user_id = ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + int i = 1; + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.TAMING)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.MINING)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.REPAIR)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.WOODCUTTING)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.UNARMED)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.HERBALISM)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.EXCAVATION)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.ARCHERY)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.SWORDS)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.AXES)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.ACROBATICS)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.FISHING)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.ALCHEMY)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.CROSSBOWS)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.TRIDENTS)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.MACES)); + stmt.setInt(i++, profile.getSkillXpLevel(PrimarySkillType.SPEARS)); + stmt.setInt(i, userId); + + if (stmt.executeUpdate() == 0) { + logger.severe("Failed to update experience for " + playerName); + return false; + } + return true; + } catch (SQLException ex) { + logSQLException(ex); + return false; + } + } + + private boolean updateCooldowns(Connection connection, int userId, PlayerProfile profile, + String playerName) { + String sql = "UPDATE " + tablePrefix + "cooldowns SET " + + " mining = ?, woodcutting = ?, unarmed = ?" + + ", herbalism = ?, excavation = ?, swords = ?" + + ", axes = ?, blast_mining = ?, chimaera_wing = ?, crossbows = ?" + + ", tridents = ?, maces = ?, spears = ?" + + " WHERE user_id = ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + int i = 1; + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.SUPER_BREAKER)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.TREE_FELLER)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.BERSERK)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.GREEN_TERRA)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.GIGA_DRILL_BREAKER)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.SERRATED_STRIKES)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.SKULL_SPLITTER)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.BLAST_MINING)); + stmt.setLong(i++, profile.getUniqueData(UniqueDataType.CHIMAERA_WING_DATS)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.SUPER_SHOTGUN)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.TRIDENTS_SUPER_ABILITY)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.MACES_SUPER_ABILITY)); + stmt.setLong(i++, profile.getAbilityDATS(SuperAbilityType.SPEARS_SUPER_ABILITY)); + stmt.setInt(i, userId); + + if (stmt.executeUpdate() == 0) { + logger.severe("Failed to update cooldowns for " + playerName); + return false; + } + return true; + } catch (SQLException ex) { + logSQLException(ex); + return false; + } + } + + private boolean updateHudSettings(Connection connection, int userId, PlayerProfile profile, + String playerName) { + String sql = "UPDATE " + tablePrefix + + "huds SET mobhealthbar = ?, scoreboardtips = ? WHERE user_id = ?"; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setString(1, MobHealthbarType.HEARTS.name()); + stmt.setInt(2, profile.getScoreboardTipsShown()); + stmt.setInt(3, userId); + + if (stmt.executeUpdate() == 0) { + logger.severe("Failed to update hud settings for " + playerName); + return false; + } + return true; + } catch (SQLException ex) { + logSQLException(ex); + return false; + } + } + + // --------------------------------------------------------------------- + // Leaderboards / rank + // --------------------------------------------------------------------- + public @NotNull List readLeaderboard(@Nullable PrimarySkillType skill, - int pageNumber, int statsPerPage) throws InvalidSkillException { + int pageNumber, + int statsPerPage) throws InvalidSkillException { List stats = new ArrayList<>(); - //Fix for a plugin that people are using that is throwing SQL errors + // Fix for a plugin that people are using that is throwing SQL errors if (skill != null && SkillTools.isChildSkill(skill)) { logger.severe( - "A plugin hooking into mcMMO is being naughty with our database commands, update all plugins that hook into mcMMO and contact their devs!"); + "A plugin hooking into mcMMO is being naughty with our database commands, update all plugins that hook into mcMMO and contact their devs!" + ); throw new InvalidSkillException( - "A plugin hooking into mcMMO that you are using is attempting to read leaderboard skills for child skills, child skills do not have leaderboards! This is NOT an mcMMO error!"); + "A plugin hooking into mcMMO that you are using is attempting to read leaderboard skills for child skills, child skills do not have leaderboards! This is NOT an mcMMO error!" + ); } - String query = skill == null ? ALL_QUERY_VERSION : skill.name().toLowerCase(Locale.ENGLISH); - ResultSet resultSet = null; - PreparedStatement statement = null; - Connection connection = null; + String query = (skill == null) + ? ALL_QUERY_VERSION + : skill.name().toLowerCase(Locale.ENGLISH); + + String sql = "SELECT " + query + ", `user` FROM " + tablePrefix + "users " + + "JOIN " + tablePrefix + "skills ON (user_id = id) " + + "WHERE " + query + " > 0 " + + "AND NOT `user` = '\\_INVALID\\_OLD\\_USERNAME\\_' " + + "ORDER BY " + query + " DESC, `user` LIMIT ?, ?"; + + try (Connection connection = getConnection(PoolIdentifier.MISC); + PreparedStatement statement = connection.prepareStatement(sql)) { - try { - connection = getConnection(PoolIdentifier.MISC); - statement = connection.prepareStatement( - "SELECT " + query + ", `user` FROM " + tablePrefix + "users JOIN " + tablePrefix - + "skills ON (user_id = id) WHERE " + query - + " > 0 AND NOT `user` = '\\_INVALID\\_OLD\\_USERNAME\\_' ORDER BY " - + query + " DESC, `user` LIMIT ?, ?"); statement.setInt(1, (pageNumber * statsPerPage) - statsPerPage); statement.setInt(2, statsPerPage); - resultSet = statement.executeQuery(); - while (resultSet.next()) { - ArrayList column = new ArrayList<>(); - - for (int i = 1; i <= resultSet.getMetaData().getColumnCount(); i++) { - column.add(resultSet.getString(i)); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + // 1st col = value, 2nd col = username + int value = resultSet.getInt(1); + String playerName = resultSet.getString(2); + stats.add(new PlayerStat(playerName, value)); } - - stats.add(new PlayerStat(column.get(1), Integer.parseInt(column.get(0)))); } } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(resultSet); - tryClose(statement); - tryClose(connection); + logSQLException(ex); } return stats; } public Map readRank(String playerName) { - Map skills = new HashMap<>(); + // NOTE: We keep HashMap so we can still use `null` as the "total" key, + // just like the original code. + Map ranks = new HashMap<>(); - ResultSet resultSet = null; - PreparedStatement statement = null; - Connection connection = null; + // Preload this player's skill levels & total in a single query + try (Connection connection = getConnection(PoolIdentifier.MISC)) { - try { - connection = getConnection(PoolIdentifier.MISC); - for (PrimarySkillType primarySkillType : SkillTools.NON_CHILD_SKILLS) { - String skillName = primarySkillType.name().toLowerCase(Locale.ENGLISH); - // Get count of all users with higher skill level than player - String sql = "SELECT COUNT(*) AS 'rank' FROM " + tablePrefix + "users JOIN " - + tablePrefix + "skills ON user_id = id WHERE " + skillName + " > 0 " + - "AND " + skillName + " > (SELECT " + skillName + " FROM " + tablePrefix - + "users JOIN " + tablePrefix + "skills ON user_id = id " + - "WHERE `user` = ?)"; + // 1) Load all relevant skill levels for this player in one shot + Map levels = new EnumMap<>(PrimarySkillType.class); + int totalLevel = 0; - statement = connection.prepareStatement(sql); - statement.setString(1, playerName); - resultSet = statement.executeQuery(); + String loadSql = + "SELECT s.*, u.`user` " + + "FROM " + tablePrefix + "users u " + + "JOIN " + tablePrefix + "skills s ON s.user_id = u.id " + + "WHERE u.`user` = ?"; - resultSet.next(); + try (PreparedStatement stmt = connection.prepareStatement(loadSql)) { + stmt.setString(1, playerName); - int rank = resultSet.getInt("rank"); - - // Ties are settled by alphabetical order - sql = "SELECT user, " + skillName + " FROM " + tablePrefix + "users JOIN " - + tablePrefix + "skills ON user_id = id WHERE " + skillName + " > 0 " + - "AND " + skillName + " = (SELECT " + skillName + " FROM " + tablePrefix - + "users JOIN " + tablePrefix + "skills ON user_id = id " + - "WHERE `user` = '" + playerName + "') ORDER BY user"; - - resultSet.close(); - statement.close(); - - statement = connection.prepareStatement(sql); - resultSet = statement.executeQuery(); - - while (resultSet.next()) { - if (resultSet.getString("user").equalsIgnoreCase(playerName)) { - skills.put(primarySkillType, rank + resultSet.getRow()); - break; + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + // Player not found in DB, no ranks to report + return ranks; } - } - resultSet.close(); - statement.close(); - } + for (PrimarySkillType primarySkillType : SkillTools.NON_CHILD_SKILLS) { + String column = primarySkillType.name().toLowerCase(Locale.ENGLISH); + levels.put(primarySkillType, rs.getInt(column)); + } - String sql = - "SELECT COUNT(*) AS 'rank' FROM " + tablePrefix + "users JOIN " + tablePrefix - + "skills ON user_id = id " + - "WHERE " + ALL_QUERY_VERSION + " > 0 " + - "AND " + ALL_QUERY_VERSION + " > " + - "(SELECT " + ALL_QUERY_VERSION + " " + - "FROM " + tablePrefix + "users JOIN " + tablePrefix - + "skills ON user_id = id WHERE `user` = ?)"; - - statement = connection.prepareStatement(sql); - statement.setString(1, playerName); - resultSet = statement.executeQuery(); - - resultSet.next(); - - int rank = resultSet.getInt("rank"); - - resultSet.close(); - statement.close(); - - sql = "SELECT user, " + ALL_QUERY_VERSION + " " + - "FROM " + tablePrefix + "users JOIN " + tablePrefix + "skills ON user_id = id " - + - "WHERE " + ALL_QUERY_VERSION + " > 0 " + - "AND " + ALL_QUERY_VERSION + " = " + - "(SELECT " + ALL_QUERY_VERSION + " " + - "FROM " + tablePrefix + "users JOIN " + tablePrefix - + "skills ON user_id = id WHERE `user` = ?) ORDER BY user"; - - statement = connection.prepareStatement(sql); - statement.setString(1, playerName); - resultSet = statement.executeQuery(); - - while (resultSet.next()) { - if (resultSet.getString("user").equalsIgnoreCase(playerName)) { - skills.put(null, rank + resultSet.getRow()); - break; + totalLevel = rs.getInt(ALL_QUERY_VERSION); // "total" column + } + } + + // Helper method to compute a rank (base + tie offset + 1) + // for any numeric column on the skills table. + class RankCalculator { + int computeRank(String columnName, int value) throws SQLException { + if (value <= 0) { + // Original logic effectively did not assign a rank when the value <= 0 + return -1; + } + + // Base: number of players with strictly higher value + String higherSql = + "SELECT COUNT(*) AS cnt " + + "FROM " + tablePrefix + "users u " + + "JOIN " + tablePrefix + "skills s ON s.user_id = u.id " + + "WHERE s." + columnName + " > ?"; + + int higherCount = 0; + try (PreparedStatement stmt = connection.prepareStatement(higherSql)) { + stmt.setInt(1, value); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + higherCount = rs.getInt("cnt"); + } + } + } + + // Tie offset: number of players with the same value whose username + // sorts alphabetically before this player's name. + String tieSql = + "SELECT COUNT(*) AS cnt " + + "FROM " + tablePrefix + "users u " + + "JOIN " + tablePrefix + "skills s ON s.user_id = u.id " + + "WHERE s." + columnName + " = ? " + + "AND s." + columnName + " > 0 " + + "AND u.`user` < ?"; + + int tieCount = 0; + try (PreparedStatement stmt = connection.prepareStatement(tieSql)) { + stmt.setInt(1, value); + stmt.setString(2, playerName); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + tieCount = rs.getInt("cnt"); + } + } + } + + // 1-based rank: higher values first, then alphabetical by username + return higherCount + tieCount + 1; + } + } + + RankCalculator rankCalculator = new RankCalculator(); + + // 2) Per-skill rank + for (PrimarySkillType primarySkillType : SkillTools.NON_CHILD_SKILLS) { + int level = levels.getOrDefault(primarySkillType, 0); + + int rank = rankCalculator.computeRank( + primarySkillType.name().toLowerCase(Locale.ENGLISH), + level + ); + + if (rank > 0) { + ranks.put(primarySkillType, rank); + } + } + + // 3) Total rank (null key matches original behavior) + if (totalLevel > 0) { + int totalRank = rankCalculator.computeRank(ALL_QUERY_VERSION, totalLevel); + if (totalRank > 0) { + ranks.put(null, totalRank); } } - resultSet.close(); - statement.close(); } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(resultSet); - tryClose(statement); - tryClose(connection); + logSQLException(ex); } - return skills; + return ranks; } - public @NotNull PlayerProfile newUser(String playerName, UUID uuid) { - Connection connection = null; - try { - connection = getConnection(PoolIdentifier.MISC); + // --------------------------------------------------------------------- + // New user / load profile + // --------------------------------------------------------------------- + + public @NotNull PlayerProfile newUser(String playerName, UUID uuid) { + try (Connection connection = getConnection(PoolIdentifier.MISC)) { newUser(connection, playerName, uuid); } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(connection); + logSQLException(ex); } - return new PlayerProfile(playerName, uuid, true, - mcMMO.p.getAdvancedConfig().getStartingLevel()); + return new PlayerProfile( + playerName, + uuid, + true, + mcMMO.p.getAdvancedConfig().getStartingLevel() + ); } @Override public @NotNull PlayerProfile newUser(@NotNull Player player) { - try { - Connection connection = getConnection(PoolIdentifier.SAVE); + try (Connection connection = getConnection(PoolIdentifier.SAVE)) { int id = newUser(connection, player.getName(), player.getUniqueId()); if (id == -1) { - return new PlayerProfile(player.getName(), player.getUniqueId(), false, - mcMMO.p.getAdvancedConfig().getStartingLevel()); + return new PlayerProfile( + player.getName(), + player.getUniqueId(), + false, + mcMMO.p.getAdvancedConfig().getStartingLevel() + ); } else { return loadPlayerProfile(player); } } catch (SQLException e) { - e.printStackTrace(); + logger.log(Level.SEVERE, + "Unexpected SQLException while creating new user for " + player.getName(), e); } - return new PlayerProfile(player.getName(), player.getUniqueId(), false, - mcMMO.p.getAdvancedConfig().getStartingLevel()); + return new PlayerProfile( + player.getName(), + player.getUniqueId(), + false, + mcMMO.p.getAdvancedConfig().getStartingLevel() + ); } - private int newUser(Connection connection, String playerName, UUID uuid) { - ResultSet resultSet = null; - PreparedStatement statement = null; + private int newUser(Connection connection, String playerName, @Nullable UUID uuid) { + Objects.requireNonNull(connection, "connection must not be null"); - try { - statement = connection.prepareStatement( - "UPDATE `" + tablePrefix + "users` " - + "SET `user` = ? " - + "WHERE `user` = ?"); - statement.setString(1, "_INVALID_OLD_USERNAME_"); - statement.setString(2, playerName); - statement.executeUpdate(); - statement.close(); - - statement = connection.prepareStatement("INSERT INTO " + tablePrefix - + "users (user, uuid, lastlogin) VALUES (?, ?, UNIX_TIMESTAMP())", - Statement.RETURN_GENERATED_KEYS); - statement.setString(1, playerName); - statement.setString(2, uuid != null ? uuid.toString() : null); - statement.executeUpdate(); - - resultSet = statement.getGeneratedKeys(); - - if (!resultSet.next()) { - logger.severe("Unable to create new user account in DB"); - return -1; - } - - writeMissingRows(connection, resultSet.getInt(1)); - return resultSet.getInt(1); - } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(resultSet); - tryClose(statement); + if (playerName == null || playerName.isEmpty()) { + logger.severe("Attempted to create user with null/empty playerName"); + return -1; + } + + // Step 1: Invalidate any existing rows with the same username + String invalidateSql = + "UPDATE `" + tablePrefix + "users` " + + "SET `user` = ? " + + "WHERE `user` = ?"; + + try (PreparedStatement invalidateStmt = connection.prepareStatement(invalidateSql)) { + invalidateStmt.setString(1, INVALID_OLD_USERNAME); + invalidateStmt.setString(2, playerName); + invalidateStmt.executeUpdate(); + } catch (SQLException ex) { + logSQLException(ex); + return -1; + } + + // Step 2: Insert the new user and fetch the generated id + String insertSql = + "INSERT INTO " + tablePrefix + + "users (user, uuid, lastlogin) VALUES (?, ?, UNIX_TIMESTAMP())"; + + try (PreparedStatement insertStmt = connection.prepareStatement( + insertSql, Statement.RETURN_GENERATED_KEYS)) { + + insertStmt.setString(1, playerName); + insertStmt.setString(2, uuid != null ? uuid.toString() : null); + insertStmt.executeUpdate(); + + try (ResultSet keys = insertStmt.getGeneratedKeys()) { + if (!keys.next()) { + logger.severe( + "Unable to create new user account in DB for player '" + playerName + + "'"); + return -1; + } + + int userId = keys.getInt(1); + writeMissingRows(connection, userId); + return userId; + } + } catch (SQLException ex) { + logSQLException(ex); + return -1; } - return -1; } public @NotNull PlayerProfile loadPlayerProfile(@NotNull String playerName) { try { return loadPlayerFromDB(null, playerName); } catch (RuntimeException e) { - e.printStackTrace(); - return new PlayerProfile(playerName, false, - mcMMO.p.getAdvancedConfig().getStartingLevel()); + mcMMO.p.getLogger().log(Level.SEVERE, + "Unexpected error while loading player profile for " + playerName, e); + return new PlayerProfile( + playerName, + false, + mcMMO.p.getAdvancedConfig().getStartingLevel() + ); } } @@ -680,185 +823,289 @@ public final class SQLDatabaseManager implements DatabaseManager { } private PlayerProfile loadPlayerFromDB(@Nullable UUID uuid, @Nullable String playerName) - throws RuntimeException { + throws IllegalArgumentException { + if (uuid == null && playerName == null) { - throw new RuntimeException( - "Error looking up player, both UUID and playerName are null and one must not be."); + throw new IllegalArgumentException( + "Error looking up player, both UUID and playerName are null and one must not be." + ); } - PreparedStatement statement = null; - Connection connection = null; - ResultSet resultSet = null; - - try { - connection = getConnection(PoolIdentifier.LOAD); + try (Connection connection = getConnection(PoolIdentifier.LOAD)) { int id = getUserID(connection, playerName, uuid); if (id == -1) { - // There is no such user - return new PlayerProfile(playerName, - mcMMO.p.getAdvancedConfig().getStartingLevel()); + return createEmptyProfile(playerName, uuid); } - // There is such a user + writeMissingRows(connection, id); - statement = connection.prepareStatement( + String sql = "SELECT " + - "s.taming, s.mining, s.repair, s.woodcutting, s.unarmed, s.herbalism, s.excavation, s.archery, s.swords, s.axes, s.acrobatics, s.fishing, s.alchemy, s.crossbows, s.tridents, s.maces, " - + - "e.taming, e.mining, e.repair, e.woodcutting, e.unarmed, e.herbalism, e.excavation, e.archery, e.swords, e.axes, e.acrobatics, e.fishing, e.alchemy, e.crossbows, e.tridents, e.maces, " - + - "c.taming, c.mining, c.repair, c.woodcutting, c.unarmed, c.herbalism, c.excavation, c.archery, c.swords, c.axes, c.acrobatics, c.blast_mining, c.chimaera_wing, c.crossbows, c.tridents, c.maces, " - + - "h.mobhealthbar, h.scoreboardtips, u.uuid, u.`user` " - + "FROM " + tablePrefix + "users u " - + "JOIN " + tablePrefix + "skills s ON (u.id = s.user_id) " - + "JOIN " + tablePrefix + "experience e ON (u.id = e.user_id) " - + "JOIN " + tablePrefix + "cooldowns c ON (u.id = c.user_id) " - + "JOIN " + tablePrefix + "huds h ON (u.id = h.user_id) " - + "WHERE u.id = ?" - ); - statement.setInt(1, id); + // --- skills (levels) --- + "s.taming AS skill_taming, " + + "s.mining AS skill_mining, " + + "s.repair AS skill_repair, " + + "s.woodcutting AS skill_woodcutting, " + + "s.unarmed AS skill_unarmed, " + + "s.herbalism AS skill_herbalism, " + + "s.excavation AS skill_excavation, " + + "s.archery AS skill_archery, " + + "s.swords AS skill_swords, " + + "s.axes AS skill_axes, " + + "s.acrobatics AS skill_acrobatics, " + + "s.fishing AS skill_fishing, " + + "s.alchemy AS skill_alchemy, " + + "s.crossbows AS skill_crossbows, " + + "s.tridents AS skill_tridents, " + + "s.maces AS skill_maces, " + + "s.spears AS skill_spears, " + - resultSet = statement.executeQuery(); + // --- skills XP --- + "e.taming AS xp_taming, " + + "e.mining AS xp_mining, " + + "e.repair AS xp_repair, " + + "e.woodcutting AS xp_woodcutting, " + + "e.unarmed AS xp_unarmed, " + + "e.herbalism AS xp_herbalism, " + + "e.excavation AS xp_excavation, " + + "e.archery AS xp_archery, " + + "e.swords AS xp_swords, " + + "e.axes AS xp_axes, " + + "e.acrobatics AS xp_acrobatics, " + + "e.fishing AS xp_fishing, " + + "e.alchemy AS xp_alchemy, " + + "e.crossbows AS xp_crossbows, " + + "e.tridents AS xp_tridents, " + + "e.maces AS xp_maces, " + + "e.spears AS xp_spears, " + - if (resultSet.next()) { - try { - PlayerProfile profile = loadFromResult(playerName, resultSet); - String name = resultSet.getString( - MAGIC_NUMBER); // TODO: Magic Number, make sure it stays updated - resultSet.close(); - statement.close(); + // --- cooldowns / unique data --- + "c.mining AS cd_super_breaker, " + + "c.repair AS cd_repair_unused, " + + "c.woodcutting AS cd_tree_feller, " + + "c.unarmed AS cd_berserk, " + + "c.herbalism AS cd_green_terra, " + + "c.excavation AS cd_giga_drill_breaker, " + + "c.archery AS cd_explosive_shot, " + + "c.swords AS cd_serrated_strikes, " + + "c.axes AS cd_skull_splitter, " + + "c.acrobatics AS cd_acrobatics_unused, " + + "c.blast_mining AS cd_blast_mining, " + + "c.chimaera_wing AS ud_chimaera_wing_dats, " + + "c.crossbows AS cd_super_shotgun, " + + "c.tridents AS cd_tridents_super_ability, " + + "c.maces AS cd_maces_super_ability, " + + "c.spears AS cd_spears_super_ability, " + - if (playerName != null - && !playerName.isEmpty() - && !playerName.equalsIgnoreCase(name) - && uuid != null) { - statement = connection.prepareStatement( - "UPDATE `" + tablePrefix + "users` " - + "SET `user` = ? " - + "WHERE `user` = ?"); - statement.setString(1, "_INVALID_OLD_USERNAME_"); - statement.setString(2, name); - statement.executeUpdate(); - statement.close(); - statement = connection.prepareStatement( - "UPDATE `" + tablePrefix + "users` " - + "SET `user` = ?, uuid = ? " - + "WHERE id = ?"); - statement.setString(1, playerName); - statement.setString(2, uuid.toString()); - statement.setInt(3, id); - statement.executeUpdate(); - statement.close(); + // --- HUD + user info --- + "h.mobhealthbar AS mobhealthbar, " + + "h.scoreboardtips AS scoreboardtips, " + + "u.uuid AS uuid, " + + "u.`user` AS username " + + "FROM " + tablePrefix + "users u " + + "JOIN " + tablePrefix + "skills s ON (u.id = s.user_id) " + + "JOIN " + tablePrefix + "experience e ON (u.id = e.user_id) " + + "JOIN " + tablePrefix + "cooldowns c ON (u.id = c.user_id) " + + "JOIN " + tablePrefix + "huds h ON (u.id = h.user_id) " + + "WHERE u.id = ?"; + + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, id); + + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) { + return createEmptyProfile(playerName, uuid); } - return profile; - } catch (SQLException e) { - printErrors(e); + String nameInDb = resultSet.getString("username"); + + if (shouldUpdateUsername(playerName, uuid, nameInDb)) { + invalidateOldUsername(connection, nameInDb); + updateCurrentUsername(connection, id, playerName, uuid); + } + + if (playerName == null || playerName.isEmpty()) { + playerName = nameInDb; + } + + return loadFromResult(playerName, resultSet); } } - resultSet.close(); } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(resultSet); - tryClose(statement); - tryClose(connection); + logSQLException(ex); + return createEmptyProfile(playerName, uuid); } - - //Return empty profile - return new PlayerProfile(playerName, mcMMO.p.getAdvancedConfig().getStartingLevel()); } - public void convertUsers(DatabaseManager destination) { - PreparedStatement statement = null; - Connection connection = null; - ResultSet resultSet = null; + private PlayerProfile createEmptyProfile(@Nullable String playerName, @Nullable UUID uuid) { + return new PlayerProfile(playerName, uuid, mcMMO.p.getAdvancedConfig().getStartingLevel()); + } - try { - connection = getConnection(PoolIdentifier.MISC); - statement = connection.prepareStatement( - "SELECT " - + "s.taming, s.mining, s.repair, s.woodcutting, s.unarmed, s.herbalism, s.excavation, s.archery, s.swords, s.axes, s.acrobatics, s.fishing, s.alchemy, " - + "e.taming, e.mining, e.repair, e.woodcutting, e.unarmed, e.herbalism, e.excavation, e.archery, e.swords, e.axes, e.acrobatics, e.fishing, e.alchemy, " - + "c.taming, c.mining, c.repair, c.woodcutting, c.unarmed, c.herbalism, c.excavation, c.archery, c.swords, c.axes, c.acrobatics, c.blast_mining, c.chimaera_wing, " - + "h.mobhealthbar, h.scoreboardtips, u.uuid " - + "FROM " + tablePrefix + "users u " - + "JOIN " + tablePrefix + "skills s ON (u.id = s.user_id) " - + "JOIN " + tablePrefix + "experience e ON (u.id = e.user_id) " - + "JOIN " + tablePrefix + "cooldowns c ON (u.id = c.user_id) " - + "JOIN " + tablePrefix + "huds h ON (u.id = h.user_id) " - + "WHERE u.`user` = ?"); - List usernames = getStoredUsers(); - int convertedUsers = 0; - long startMillis = System.currentTimeMillis(); - for (String playerName : usernames) { - statement.setString(1, playerName); - try { - resultSet = statement.executeQuery(); - resultSet.next(); - destination.saveUser(loadFromResult(playerName, resultSet)); - resultSet.close(); - } catch (SQLException e) { - printErrors(e); - // Ignore - } - convertedUsers++; - Misc.printProgress(convertedUsers, progressInterval, startMillis); - } - } catch (SQLException e) { - printErrors(e); - } finally { - tryClose(resultSet); - tryClose(statement); - tryClose(connection); + private boolean shouldUpdateUsername(@Nullable String playerName, + @Nullable UUID uuid, + String nameInDb) { + return playerName != null + && !playerName.isEmpty() + && !playerName.equalsIgnoreCase(nameInDb) + && uuid != null; + } + + private void invalidateOldUsername(Connection connection, String oldName) throws SQLException { + String sql = "UPDATE `" + tablePrefix + "users` SET `user` = ? WHERE `user` = ?"; + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setString(1, INVALID_OLD_USERNAME); + stmt.setString(2, oldName); + stmt.executeUpdate(); + } + } + + private void updateCurrentUsername(Connection connection, + int id, + String playerName, + UUID uuid) throws SQLException { + String sql = "UPDATE `" + tablePrefix + "users` SET `user` = ?, uuid = ? WHERE id = ?"; + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setString(1, playerName); + stmt.setString(2, uuid.toString()); + stmt.setInt(3, id); + stmt.executeUpdate(); + } + } + + private PlayerProfile loadFromResult(String playerName, ResultSet result) throws SQLException { + final var skills = new EnumMap(PrimarySkillType.class); + final var skillsXp = new EnumMap(PrimarySkillType.class); + final var skillsDATS = new EnumMap(SuperAbilityType.class); + final var uniqueData = new EnumMap(UniqueDataType.class); + + // --- Skills & XP by predictable alias name --- + + for (PrimarySkillType skill : PERSISTED_SKILLS) { + String base = skill.name().toLowerCase(Locale.ROOT); + + int level = result.getInt("skill_" + base); + float xp = result.getFloat("xp_" + base); + + skills.put(skill, level); + skillsXp.put(skill, xp); } + // --- Cooldowns / DATS --- + + skillsDATS.put(SuperAbilityType.SUPER_BREAKER, + result.getInt("cd_super_breaker")); + skillsDATS.put(SuperAbilityType.TREE_FELLER, + result.getInt("cd_tree_feller")); + skillsDATS.put(SuperAbilityType.BERSERK, + result.getInt("cd_berserk")); + skillsDATS.put(SuperAbilityType.GREEN_TERRA, + result.getInt("cd_green_terra")); + skillsDATS.put(SuperAbilityType.GIGA_DRILL_BREAKER, + result.getInt("cd_giga_drill_breaker")); + skillsDATS.put(SuperAbilityType.EXPLOSIVE_SHOT, + result.getInt("cd_explosive_shot")); + skillsDATS.put(SuperAbilityType.SERRATED_STRIKES, + result.getInt("cd_serrated_strikes")); + skillsDATS.put(SuperAbilityType.SKULL_SPLITTER, + result.getInt("cd_skull_splitter")); + skillsDATS.put(SuperAbilityType.BLAST_MINING, + result.getInt("cd_blast_mining")); + skillsDATS.put(SuperAbilityType.SUPER_SHOTGUN, + result.getInt("cd_super_shotgun")); + skillsDATS.put(SuperAbilityType.TRIDENTS_SUPER_ABILITY, + result.getInt("cd_tridents_super_ability")); + skillsDATS.put(SuperAbilityType.MACES_SUPER_ABILITY, + result.getInt("cd_maces_super_ability")); + skillsDATS.put(SuperAbilityType.SPEARS_SUPER_ABILITY, + result.getInt("cd_spears_super_ability")); + + uniqueData.put(UniqueDataType.CHIMAERA_WING_DATS, + result.getInt("ud_chimaera_wing_dats")); + + // --- HUD + UUID --- + + int scoreboardTipsShown; + try { + scoreboardTipsShown = result.getInt("scoreboardtips"); + } catch (SQLException | RuntimeException ignored) { + scoreboardTipsShown = 0; + } + + UUID uuid = null; + try { + String uuidString = result.getString("uuid"); + if (uuidString != null && !uuidString.isEmpty()) { + uuid = UUID.fromString(uuidString); + } + } catch (SQLException | IllegalArgumentException ignored) { + // keep uuid null + } + + return new PlayerProfile(playerName, uuid, skills, skillsXp, skillsDATS, + scoreboardTipsShown, uniqueData, null); + } + + // --------------------------------------------------------------------- + // Cross-database conversion + // --------------------------------------------------------------------- + + public void convertUsers(DatabaseManager destination) { + final List usernames = getStoredUsers(); + if (usernames.isEmpty()) { + logger.info("No stored users found to convert."); + return; + } + + int convertedUsers = 0; + long startMillis = System.currentTimeMillis(); + int progressInterval = 1000; // use existing Misc.printProgress behavior + + for (String playerName : usernames) { + try { + final PlayerProfile profile = loadPlayerProfile(playerName); + destination.saveUser(profile); + } catch (Exception ex) { + logger.log(Level.SEVERE, "Failed to convert user '" + playerName + "'", ex); + } + + convertedUsers++; + Misc.printProgress(convertedUsers, progressInterval, startMillis); + } + + logger.info("Finished converting " + convertedUsers + " users."); } public boolean saveUserUUID(String userName, UUID uuid) { - PreparedStatement statement = null; - Connection connection = null; + String sql = "UPDATE `" + tablePrefix + "users` SET uuid = ? WHERE `user` = ?"; + + try (Connection connection = getConnection(PoolIdentifier.MISC); + PreparedStatement statement = connection.prepareStatement(sql)) { - try { - connection = getConnection(PoolIdentifier.MISC); - statement = connection.prepareStatement( - "UPDATE `" + tablePrefix + "users` SET " - + " uuid = ? WHERE `user` = ?"); statement.setString(1, uuid.toString()); statement.setString(2, userName); statement.execute(); return true; } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); return false; - } finally { - tryClose(statement); - tryClose(connection); } } public boolean saveUserUUIDs(Map fetchedUUIDs) { - PreparedStatement statement = null; + String sql = "UPDATE " + tablePrefix + "users SET uuid = ? WHERE `user` = ?"; int count = 0; - Connection connection = null; - - try { - connection = getConnection(PoolIdentifier.MISC); - statement = connection.prepareStatement( - "UPDATE " + tablePrefix + "users SET uuid = ? WHERE `user` = ?"); + try (Connection connection = getConnection(PoolIdentifier.MISC); + PreparedStatement statement = connection.prepareStatement(sql)) { for (Map.Entry entry : fetchedUUIDs.entrySet()) { statement.setString(1, entry.getValue().toString()); statement.setString(2, entry.getKey()); - statement.addBatch(); count++; - - if ((count % 500) == 0) { + if (count % 500 == 0) { statement.executeBatch(); count = 0; } @@ -870,311 +1117,341 @@ public final class SQLDatabaseManager implements DatabaseManager { return true; } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); return false; - } finally { - tryClose(statement); - tryClose(connection); } } public List getStoredUsers() { - ArrayList users = new ArrayList<>(); + List users = new ArrayList<>(); - Statement statement = null; - Connection connection = null; - ResultSet resultSet = null; + String sql = "SELECT `user` FROM " + tablePrefix + "users"; + + try (Connection connection = getConnection(PoolIdentifier.MISC); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery(sql)) { - try { - connection = getConnection(PoolIdentifier.MISC); - statement = connection.createStatement(); - resultSet = statement.executeQuery("SELECT `user` FROM " + tablePrefix + "users"); while (resultSet.next()) { users.add(resultSet.getString("user")); } } catch (SQLException e) { - printErrors(e); - } finally { - tryClose(resultSet); - tryClose(statement); - tryClose(connection); + logSQLException(e); } return users; } + // --------------------------------------------------------------------- + // Schema / structure + // --------------------------------------------------------------------- + /** - * Checks that the database structure is present and correct + * Checks that the database structure is present and correct. Runs once on startup. */ private void checkStructure() { - PreparedStatement statement = null; - Statement createStatement = null; - ResultSet resultSet = null; - Connection connection = null; + try (Connection connection = getConnection(PoolIdentifier.MISC)) { + final String schemaQuery = + "SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = ?" + + " AND table_name = ?"; - try { - connection = getConnection(PoolIdentifier.MISC); - String schemaQuery = this.h2 - ? "SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_name = ?" - : "SELECT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = ? AND table_name = ?"; - - statement = connection.prepareStatement(schemaQuery); - - setStatementQuery(statement, "users"); - - resultSet = statement.executeQuery(); - - if (!resultSet.next()) { - createStatement = connection.createStatement(); - String sql = "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "users` (" + - "`id` int AUTO_INCREMENT," + - "`user` varchar(40) NOT NULL," + - "`uuid` varchar(36)," + - "`lastlogin` bigint NOT NULL," + - "PRIMARY KEY (`id`)," + - "INDEX `user_index`(`user`)," + - "UNIQUE(`uuid`))"; - createStatement.executeUpdate(sql); - tryClose(createStatement); + try (PreparedStatement schemaStmt = connection.prepareStatement(schemaQuery)) { + ensureUsersTable(connection, schemaStmt); + ensureHudsTable(connection, schemaStmt); + ensureCooldownsTable(connection, schemaStmt); + ensureSkillsTable(connection, schemaStmt); + ensureExperienceTable(connection, schemaStmt); } - tryClose(resultSet); - setStatementQuery(statement, "huds"); - resultSet = statement.executeQuery(); - if (!resultSet.next()) { - createStatement = connection.createStatement(); - createStatement.executeUpdate( - "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "huds` (" - + "`user_id` int(10) unsigned NOT NULL," - + "`mobhealthbar` varchar(50) NOT NULL DEFAULT '" - + mcMMO.p.getGeneralConfig().getMobHealthbarDefault() + "'," - + "`scoreboardtips` int(10) NOT NULL DEFAULT '0'," - + "PRIMARY KEY (`user_id`)) " - + "DEFAULT CHARSET=" + CHARSET_SQL + ";"); - tryClose(createStatement); - } - tryClose(resultSet); - setStatementQuery(statement, "cooldowns"); - resultSet = statement.executeQuery(); - if (!resultSet.next()) { - createStatement = connection.createStatement(); - createStatement.executeUpdate( - "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "cooldowns` (" - + "`user_id` int(10) unsigned NOT NULL," - + "`taming` int(32) unsigned NOT NULL DEFAULT '0'," - + "`mining` int(32) unsigned NOT NULL DEFAULT '0'," - + "`woodcutting` int(32) unsigned NOT NULL DEFAULT '0'," - + "`repair` int(32) unsigned NOT NULL DEFAULT '0'," - + "`unarmed` int(32) unsigned NOT NULL DEFAULT '0'," - + "`herbalism` int(32) unsigned NOT NULL DEFAULT '0'," - + "`excavation` int(32) unsigned NOT NULL DEFAULT '0'," - + "`archery` int(32) unsigned NOT NULL DEFAULT '0'," - + "`swords` int(32) unsigned NOT NULL DEFAULT '0'," - + "`axes` int(32) unsigned NOT NULL DEFAULT '0'," - + "`acrobatics` int(32) unsigned NOT NULL DEFAULT '0'," - + "`blast_mining` int(32) unsigned NOT NULL DEFAULT '0'," - + "`chimaera_wing` int(32) unsigned NOT NULL DEFAULT '0'," - + "`crossbows` int(32) unsigned NOT NULL DEFAULT '0'," - + "`tridents` int(32) unsigned NOT NULL DEFAULT '0'," - + "`maces` int(32) unsigned NOT NULL DEFAULT '0'," - + "PRIMARY KEY (`user_id`)) " - + "DEFAULT CHARSET=" + CHARSET_SQL + ";"); - tryClose(createStatement); - } - tryClose(resultSet); - setStatementQuery(statement, "skills"); - resultSet = statement.executeQuery(); - if (!resultSet.next()) { - String startingLevel = "'" + mcMMO.p.getAdvancedConfig().getStartingLevel() + "'"; - String totalLevel = "'" + (mcMMO.p.getAdvancedConfig().getStartingLevel() * ( - PrimarySkillType.values().length - CHILD_SKILLS_SIZE)) + "'"; - createStatement = connection.createStatement(); - createStatement.executeUpdate( - "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "skills` (" - + "`user_id` int(10) unsigned NOT NULL," - + "`taming` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`mining` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`woodcutting` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`repair` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`unarmed` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`herbalism` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`excavation` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`archery` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`swords` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`axes` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," - + "`acrobatics` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`fishing` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`alchemy` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`crossbows` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`tridents` int(10) unsigned NOT NULL DEFAULT " + startingLevel - + "," - + "`maces` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," - + "`total` int(10) unsigned NOT NULL DEFAULT " + totalLevel + "," - + "PRIMARY KEY (`user_id`)) " - + "DEFAULT CHARSET=" + CHARSET_SQL + ";"); - tryClose(createStatement); - } - tryClose(resultSet); - setStatementQuery(statement, "experience"); - resultSet = statement.executeQuery(); - if (!resultSet.next()) { - createStatement = connection.createStatement(); - createStatement.executeUpdate( - "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "experience` (" - + "`user_id` int(10) unsigned NOT NULL," - + "`taming` int(10) unsigned NOT NULL DEFAULT '0'," - + "`mining` int(10) unsigned NOT NULL DEFAULT '0'," - + "`woodcutting` int(10) unsigned NOT NULL DEFAULT '0'," - + "`repair` int(10) unsigned NOT NULL DEFAULT '0'," - + "`unarmed` int(10) unsigned NOT NULL DEFAULT '0'," - + "`herbalism` int(10) unsigned NOT NULL DEFAULT '0'," - + "`excavation` int(10) unsigned NOT NULL DEFAULT '0'," - + "`archery` int(10) unsigned NOT NULL DEFAULT '0'," - + "`swords` int(10) unsigned NOT NULL DEFAULT '0'," - + "`axes` int(10) unsigned NOT NULL DEFAULT '0'," - + "`acrobatics` int(10) unsigned NOT NULL DEFAULT '0'," - + "`fishing` int(10) unsigned NOT NULL DEFAULT '0'," - + "`alchemy` int(10) unsigned NOT NULL DEFAULT '0'," - + "`crossbows` int(10) unsigned NOT NULL DEFAULT '0'," - + "`tridents` int(10) unsigned NOT NULL DEFAULT '0'," - + "PRIMARY KEY (`user_id`)) " - + "DEFAULT CHARSET=" + CHARSET_SQL + ";"); - tryClose(createStatement); - } - tryClose(resultSet); - tryClose(statement); + // Run upgrade steps for (UpgradeType updateType : UpgradeType.values()) { checkDatabaseStructure(connection, updateType); } + // Optionally truncate skills to level caps if (mcMMO.p.getGeneralConfig().getTruncateSkills()) { - for (PrimarySkillType skill : SkillTools.NON_CHILD_SKILLS) { - int cap = mcMMO.p.getSkillTools().getLevelCap(skill); - if (cap != Integer.MAX_VALUE) { - statement = connection.prepareStatement( - "UPDATE `" + tablePrefix + "skills` SET `" + skill.name() - .toLowerCase(Locale.ENGLISH) + "` = " + cap + " WHERE `" - + skill.name().toLowerCase(Locale.ENGLISH) + "` > " + cap); - statement.executeUpdate(); - tryClose(statement); - } - } + truncateSkillsToCaps(connection); } - // TODO: refactor - LogUtils.debug(logger, "Killing orphans"); - createStatement = connection.createStatement(); - createStatement.executeUpdate( - "DELETE FROM `" + tablePrefix + "experience` WHERE NOT EXISTS (SELECT * FROM `" - + tablePrefix + "users` `u` WHERE `" + tablePrefix - + "experience`.`user_id` = `u`.`id`)"); - createStatement.executeUpdate( - "DELETE FROM `" + tablePrefix + "huds` WHERE NOT EXISTS (SELECT * FROM `" - + tablePrefix + "users` `u` WHERE `" + tablePrefix - + "huds`.`user_id` = `u`.`id`)"); - createStatement.executeUpdate( - "DELETE FROM `" + tablePrefix + "cooldowns` WHERE NOT EXISTS (SELECT * FROM `" - + tablePrefix + "users` `u` WHERE `" + tablePrefix - + "cooldowns`.`user_id` = `u`.`id`)"); - createStatement.executeUpdate( - "DELETE FROM `" + tablePrefix + "skills` WHERE NOT EXISTS (SELECT * FROM `" - + tablePrefix + "users` `u` WHERE `" + tablePrefix - + "skills`.`user_id` = `u`.`id`)"); + // Clean up orphan rows + deleteOrphans(connection); } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(resultSet); - tryClose(statement); - tryClose(createStatement); - tryClose(connection); + logSQLException(ex); } + // Column-level structure updates (adds newer skill columns if missing) final String skills = "skills"; + final String experience = "experience"; + final String cooldowns = "cooldowns"; final String crossbows = "crossbows"; final String tridents = "tridents"; final String maces = "maces"; - final String experience = "experience"; - final String cooldowns = "cooldowns"; + final String spears = "spears"; - updateStructure(skills, crossbows, String.valueOf(32)); - updateStructure(skills, tridents, String.valueOf(32)); - updateStructure(skills, maces, String.valueOf(32)); + updateStructure(skills, crossbows, "32"); + updateStructure(skills, tridents, "32"); + updateStructure(skills, maces, "32"); + updateStructure(skills, spears, "32"); - updateStructure(experience, crossbows, String.valueOf(10)); - updateStructure(experience, tridents, String.valueOf(10)); - updateStructure(experience, maces, String.valueOf(10)); + updateStructure(experience, crossbows, "10"); + updateStructure(experience, tridents, "10"); + updateStructure(experience, maces, "10"); + updateStructure(experience, spears, "10"); - updateStructure(cooldowns, crossbows, String.valueOf(10)); - updateStructure(cooldowns, tridents, String.valueOf(10)); - updateStructure(cooldowns, maces, String.valueOf(10)); + updateStructure(cooldowns, crossbows, "10"); + updateStructure(cooldowns, tridents, "10"); + updateStructure(cooldowns, maces, "10"); + updateStructure(cooldowns, spears, "10"); + } + + private void ensureUsersTable(Connection connection, + PreparedStatement schemaStmt) throws SQLException { + if (tableExists(schemaStmt, "users")) { + return; + } + + String sql = "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "users` (" + + "`id` int AUTO_INCREMENT," + + "`user` varchar(40) NOT NULL," + + "`uuid` varchar(36)," + + "`lastlogin` bigint NOT NULL," + + "PRIMARY KEY (`id`)," + + "INDEX `user_index`(`user`)," + + "UNIQUE(`uuid`))"; + + try (Statement createStatement = connection.createStatement()) { + createStatement.executeUpdate(sql); + } + } + + private void ensureHudsTable(Connection connection, + PreparedStatement schemaStmt) throws SQLException { + if (tableExists(schemaStmt, "huds")) { + return; + } + + String sql = "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "huds` (" + + "`user_id` int(10) unsigned NOT NULL," + + "`mobhealthbar` varchar(50) NOT NULL DEFAULT '" + + mcMMO.p.getGeneralConfig().getMobHealthbarDefault() + "'," + + "`scoreboardtips` int(10) NOT NULL DEFAULT '0'," + + "PRIMARY KEY (`user_id`)) " + + "DEFAULT CHARSET=" + CHARSET_SQL + ";"; + + try (Statement createStatement = connection.createStatement()) { + createStatement.executeUpdate(sql); + } + } + + private void ensureCooldownsTable(Connection connection, + PreparedStatement schemaStmt) throws SQLException { + if (tableExists(schemaStmt, "cooldowns")) { + return; + } + + String sql = "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "cooldowns` (" + + "`user_id` int(10) unsigned NOT NULL," + + "`taming` int(32) unsigned NOT NULL DEFAULT '0'," + + "`mining` int(32) unsigned NOT NULL DEFAULT '0'," + + "`woodcutting` int(32) unsigned NOT NULL DEFAULT '0'," + + "`repair` int(32) unsigned NOT NULL DEFAULT '0'," + + "`unarmed` int(32) unsigned NOT NULL DEFAULT '0'," + + "`herbalism` int(32) unsigned NOT NULL DEFAULT '0'," + + "`excavation` int(32) unsigned NOT NULL DEFAULT '0'," + + "`archery` int(32) unsigned NOT NULL DEFAULT '0'," + + "`swords` int(32) unsigned NOT NULL DEFAULT '0'," + + "`axes` int(32) unsigned NOT NULL DEFAULT '0'," + + "`acrobatics` int(32) unsigned NOT NULL DEFAULT '0'," + + "`blast_mining` int(32) unsigned NOT NULL DEFAULT '0'," + + "`chimaera_wing` int(32) unsigned NOT NULL DEFAULT '0'," + + "`crossbows` int(32) unsigned NOT NULL DEFAULT '0'," + + "`tridents` int(32) unsigned NOT NULL DEFAULT '0'," + + "`maces` int(32) unsigned NOT NULL DEFAULT '0'," + + "`spears` int(32) unsigned NOT NULL DEFAULT '0'," + + "PRIMARY KEY (`user_id`)) " + + "DEFAULT CHARSET=" + CHARSET_SQL + ";"; + + try (Statement createStatement = connection.createStatement()) { + createStatement.executeUpdate(sql); + } + } + + private void ensureSkillsTable(Connection connection, + PreparedStatement schemaStmt) throws SQLException { + if (tableExists(schemaStmt, "skills")) { + return; + } + + int starting = mcMMO.p.getAdvancedConfig().getStartingLevel(); + String startingLevel = "'" + starting + "'"; + String totalLevel = + "'" + (starting * (PrimarySkillType.values().length - CHILD_SKILLS_SIZE)) + "'"; + + String sql = "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "skills` (" + + "`user_id` int(10) unsigned NOT NULL," + + "`taming` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`mining` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`woodcutting` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`repair` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`unarmed` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`herbalism` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`excavation` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`archery` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`swords` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`axes` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`acrobatics` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`fishing` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`alchemy` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`crossbows` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`tridents` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`maces` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`spears` int(10) unsigned NOT NULL DEFAULT " + startingLevel + "," + + "`total` int(10) unsigned NOT NULL DEFAULT " + totalLevel + "," + + "PRIMARY KEY (`user_id`)) " + + "DEFAULT CHARSET=" + CHARSET_SQL + ";"; + + try (Statement createStatement = connection.createStatement()) { + createStatement.executeUpdate(sql); + } + } + + private void ensureExperienceTable(Connection connection, + PreparedStatement schemaStmt) throws SQLException { + if (tableExists(schemaStmt, "experience")) { + return; + } + + String sql = "CREATE TABLE IF NOT EXISTS `" + tablePrefix + "experience` (" + + "`user_id` int(10) unsigned NOT NULL," + + "`taming` int(10) unsigned NOT NULL DEFAULT '0'," + + "`mining` int(10) unsigned NOT NULL DEFAULT '0'," + + "`woodcutting` int(10) unsigned NOT NULL DEFAULT '0'," + + "`repair` int(10) unsigned NOT NULL DEFAULT '0'," + + "`unarmed` int(10) unsigned NOT NULL DEFAULT '0'," + + "`herbalism` int(10) unsigned NOT NULL DEFAULT '0'," + + "`excavation` int(10) unsigned NOT NULL DEFAULT '0'," + + "`archery` int(10) unsigned NOT NULL DEFAULT '0'," + + "`swords` int(10) unsigned NOT NULL DEFAULT '0'," + + "`axes` int(10) unsigned NOT NULL DEFAULT '0'," + + "`acrobatics` int(10) unsigned NOT NULL DEFAULT '0'," + + "`fishing` int(10) unsigned NOT NULL DEFAULT '0'," + + "`alchemy` int(10) unsigned NOT NULL DEFAULT '0'," + + "`crossbows` int(10) unsigned NOT NULL DEFAULT '0'," + + "`tridents` int(10) unsigned NOT NULL DEFAULT '0'," + + "`maces` int(10) unsigned NOT NULL DEFAULT '0'," + + "`spears` int(10) unsigned NOT NULL DEFAULT '0'," + + "PRIMARY KEY (`user_id`)) " + + "DEFAULT CHARSET=" + CHARSET_SQL + ";"; + + try (Statement createStatement = connection.createStatement()) { + createStatement.executeUpdate(sql); + } + } + + private boolean tableExists(PreparedStatement schemaStmt, String tableName) + throws SQLException { + setStatementQuery(schemaStmt, tableName); + try (ResultSet rs = schemaStmt.executeQuery()) { + return rs.next(); + } + } + + private void truncateSkillsToCaps(Connection connection) throws SQLException { + for (PrimarySkillType skill : SkillTools.NON_CHILD_SKILLS) { + int cap = mcMMO.p.getSkillTools().getLevelCap(skill); + if (cap == Integer.MAX_VALUE) { + continue; + } + + String column = skill.name().toLowerCase(Locale.ENGLISH); + String sql = "UPDATE `" + tablePrefix + "skills` " + + "SET `" + column + "` = " + cap + " " + + "WHERE `" + column + "` > " + cap; + + try (PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.executeUpdate(); + } + } + } + + private void deleteOrphans(Connection connection) throws SQLException { + try (Statement stmt = connection.createStatement()) { + stmt.executeUpdate( + "DELETE FROM `" + tablePrefix + "experience` " + + "WHERE NOT EXISTS (SELECT * FROM `" + tablePrefix + "users` `u` " + + "WHERE `" + tablePrefix + "experience`.`user_id` = `u`.`id`)" + ); + stmt.executeUpdate( + "DELETE FROM `" + tablePrefix + "huds` " + + "WHERE NOT EXISTS (SELECT * FROM `" + tablePrefix + "users` `u` " + + "WHERE `" + tablePrefix + "huds`.`user_id` = `u`.`id`)" + ); + stmt.executeUpdate( + "DELETE FROM `" + tablePrefix + "cooldowns` " + + "WHERE NOT EXISTS (SELECT * FROM `" + tablePrefix + "users` `u` " + + "WHERE `" + tablePrefix + "cooldowns`.`user_id` = `u`.`id`)" + ); + stmt.executeUpdate( + "DELETE FROM `" + tablePrefix + "skills` " + + "WHERE NOT EXISTS (SELECT * FROM `" + tablePrefix + "users` `u` " + + "WHERE `" + tablePrefix + "skills`.`user_id` = `u`.`id`)" + ); + } } private void updateStructure(String tableName, String columnName, String columnSize) { try (Connection connection = getConnection(PoolIdentifier.MISC)) { - if (!columnExists(connection, mcMMO.p.getGeneralConfig().getMySQLDatabaseName(), - tablePrefix + tableName, columnName)) { + if (!columnExists(connection, + mcMMO.p.getGeneralConfig().getMySQLDatabaseName(), + tablePrefix + tableName, + columnName)) { + try (Statement createStatement = connection.createStatement()) { - // logger.info("[SQLDB Check] Adding column '" + columnName + "' to table '" + tablePrefix + tableName + "'..."); String startingLevel = "'" + mcMMO.p.getAdvancedConfig().getStartingLevel() + "'"; - createStatement.executeUpdate("ALTER TABLE `" + tablePrefix + tableName + "` " - + "ADD COLUMN `" + columnName + "` int(" + columnSize - + ") unsigned NOT NULL DEFAULT " + startingLevel); + String sql = "ALTER TABLE `" + tablePrefix + tableName + "` " + + "ADD COLUMN `" + columnName + "` int(" + columnSize + ") " + + "unsigned NOT NULL DEFAULT " + startingLevel; + createStatement.executeUpdate(sql); } - } else { - // logger.info("[SQLDB Check] Column '" + columnName + "' already exists in table '" + tablePrefix + tableName + "', looks good!"); } } catch (SQLException e) { - e.printStackTrace(); // Consider more robust logging + logSQLException(e); throw new RuntimeException(e); } } - private boolean columnExists(Connection connection, String database, String tableName, + private boolean columnExists(Connection connection, + String database, + String tableName, String columnName) throws SQLException { - // logger.info("[SQLDB Check] Checking if column '" + columnName + "' exists in table '" + tableName + "'"); - try (Statement createStatement = connection.createStatement()) { - String sql = "SELECT `COLUMN_NAME`\n" + - "FROM `INFORMATION_SCHEMA`.`COLUMNS`\n" + - "WHERE `TABLE_SCHEMA`='" + database + "'\n" + - " AND `TABLE_NAME`='" + tableName + "'\n" + - " AND `COLUMN_NAME`='" + columnName + "'"; - var resultSet = createStatement.executeQuery(sql); - return resultSet.next(); + String sql = "SELECT `COLUMN_NAME` " + + "FROM `INFORMATION_SCHEMA`.`COLUMNS` " + + "WHERE `TABLE_SCHEMA`='" + database + "' " + + "AND `TABLE_NAME`='" + tableName + "' " + + "AND `COLUMN_NAME`='" + columnName + "'"; + + try (Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + + return rs.next(); } catch (SQLException e) { - logger.info("Failed to check if column exists in table " + tableName + " for column " - + columnName); - e.printStackTrace(); + logger.info("Failed to check if column exists in table " + tableName + + " for column " + columnName); + logSQLException(e); throw e; } } - private void setStatementQuery(PreparedStatement statement, String tableName) throws SQLException { - if (!this.h2) { - // Set schema name for MySQL - statement.setString(1, mcMMO.p.getGeneralConfig().getMySQLDatabaseName()); - statement.setString(2, tablePrefix + tableName); - } else { - // For H2, the schema parameter is not needed - statement.setString(1, tablePrefix + tableName); - } + statement.setString(1, mcMMO.p.getGeneralConfig().getMySQLDatabaseName()); + statement.setString(2, tablePrefix + tableName); } + // --------------------------------------------------------------------- + // Upgrade system + // --------------------------------------------------------------------- + Connection getConnection(PoolIdentifier identifier) throws SQLException { Connection connection = switch (identifier) { case LOAD -> loadPool.getConnection(); @@ -1195,231 +1472,70 @@ public final class SQLDatabaseManager implements DatabaseManager { * @param upgrade Upgrade to attempt to apply */ private void checkDatabaseStructure(Connection connection, UpgradeType upgrade) { - // TODO: Rewrite / Refactor if (!mcMMO.getUpgradeManager().shouldUpgrade(upgrade)) { - LogUtils.debug(logger, "Skipping " + upgrade.name() + " upgrade (unneeded)"); return; } - Statement statement = null; - - try { - statement = connection.createStatement(); - + try (Statement statement = connection.createStatement()) { switch (upgrade) { - case ADD_FISHING: - checkUpgradeAddFishing(statement); - break; - - case ADD_BLAST_MINING_COOLDOWN: - checkUpgradeAddBlastMiningCooldown(statement); - break; - - case ADD_SQL_INDEXES: -// checkUpgradeAddSQLIndexes(statement); - break; - - case ADD_MOB_HEALTHBARS: - checkUpgradeAddMobHealthbars(statement); - break; - - case DROP_SQL_PARTY_NAMES: - checkUpgradeDropPartyNames(statement); - break; - - case DROP_SPOUT: - checkUpgradeDropSpout(statement); - break; - - case ADD_ALCHEMY: - checkUpgradeAddAlchemy(statement); - break; - - case ADD_UUIDS: + case ADD_FISHING -> checkUpgradeAddFishing(statement); + case ADD_BLAST_MINING_COOLDOWN -> checkUpgradeAddBlastMiningCooldown(statement); + case ADD_MOB_HEALTHBARS -> checkUpgradeAddMobHealthbars(statement); + case DROP_SQL_PARTY_NAMES -> checkUpgradeDropPartyNames(statement); + case DROP_SPOUT -> checkUpgradeDropSpout(statement); + case ADD_ALCHEMY -> checkUpgradeAddAlchemy(statement); + case ADD_UUIDS -> { checkUpgradeAddUUIDs(statement); - return; - - case ADD_SCOREBOARD_TIPS: + } + case ADD_SCOREBOARD_TIPS -> { checkUpgradeAddScoreboardTips(statement); - return; - - case DROP_NAME_UNIQUENESS: + } + case DROP_NAME_UNIQUENESS -> { checkNameUniqueness(statement); - return; - - case ADD_SKILL_TOTAL: - checkUpgradeSkillTotal(connection); - break; - case ADD_UNIQUE_PLAYER_DATA: - checkUpgradeAddUniqueChimaeraWing(statement); - break; - - case SQL_CHARSET_UTF8MB4: - updateCharacterSet(statement); - break; - - default: - break; - + } + case ADD_SKILL_TOTAL -> checkUpgradeSkillTotal(connection); + case ADD_UNIQUE_PLAYER_DATA -> checkUpgradeAddUniqueChimaeraWing(statement); + case SQL_CHARSET_UTF8MB4 -> updateCharacterSet(statement); + default -> { + // no-op + } } } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(statement); + logSQLException(ex); } } private void writeMissingRows(Connection connection, int id) { - PreparedStatement statement = null; + String expSql = "INSERT IGNORE INTO " + tablePrefix + "experience (user_id) VALUES (?)"; + String skillsSql = "INSERT IGNORE INTO " + tablePrefix + "skills (user_id) VALUES (?)"; + String cooldownsSql = + "INSERT IGNORE INTO " + tablePrefix + "cooldowns (user_id) VALUES (?)"; + String hudsSql = "INSERT IGNORE INTO " + tablePrefix + + "huds (user_id, mobhealthbar, scoreboardtips) VALUES (?, ?, ?)"; - try { - statement = connection.prepareStatement( - "INSERT IGNORE INTO " + tablePrefix + "experience (user_id) VALUES (?)"); - statement.setInt(1, id); - statement.execute(); - statement.close(); + try (PreparedStatement expStmt = connection.prepareStatement(expSql); + PreparedStatement skillsStmt = connection.prepareStatement(skillsSql); + PreparedStatement cdStmt = connection.prepareStatement(cooldownsSql); + PreparedStatement hudStmt = connection.prepareStatement(hudsSql)) { - statement = connection.prepareStatement( - "INSERT IGNORE INTO " + tablePrefix + "skills (user_id) VALUES (?)"); - statement.setInt(1, id); - statement.execute(); - statement.close(); + expStmt.setInt(1, id); + expStmt.execute(); - statement = connection.prepareStatement( - "INSERT IGNORE INTO " + tablePrefix + "cooldowns (user_id) VALUES (?)"); - statement.setInt(1, id); - statement.execute(); - statement.close(); + skillsStmt.setInt(1, id); + skillsStmt.execute(); - statement = connection.prepareStatement("INSERT IGNORE INTO " + tablePrefix - + "huds (user_id, mobhealthbar, scoreboardtips) VALUES (?, ?, ?)"); - statement.setInt(1, id); - statement.setString(2, mcMMO.p.getGeneralConfig().getMobHealthbarDefault().name()); - statement.setInt(3, 0); - statement.execute(); - statement.close(); + cdStmt.setInt(1, id); + cdStmt.execute(); + + hudStmt.setInt(1, id); + hudStmt.setString(2, mcMMO.p.getGeneralConfig().getMobHealthbarDefault().name()); + hudStmt.setInt(3, 0); + hudStmt.execute(); } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(statement); + logSQLException(ex); } } - private PlayerProfile loadFromResult(String playerName, ResultSet result) throws SQLException { - final Map skills = new EnumMap<>( - PrimarySkillType.class); // Skill & Level - final Map skillsXp = new EnumMap<>( - PrimarySkillType.class); // Skill & XP - final Map skillsDATS = new EnumMap<>( - SuperAbilityType.class); // Ability & Cooldown - final Map uniqueData = new EnumMap<>( - UniqueDataType.class); //Chimaera wing cooldown and other misc info - UUID uuid; - int scoreboardTipsShown; - - final int SKILL_COLUMNS = 16; - final int OFFSET_SKILLS = 0; - final int OFFSET_XP = SKILL_COLUMNS; - final int OFFSET_DATS = OFFSET_XP + SKILL_COLUMNS; - final int OFFSET_OTHER = OFFSET_DATS + SKILL_COLUMNS; - - skills.put(PrimarySkillType.TAMING, result.getInt(OFFSET_SKILLS + 1)); - skills.put(PrimarySkillType.MINING, result.getInt(OFFSET_SKILLS + 2)); - skills.put(PrimarySkillType.REPAIR, result.getInt(OFFSET_SKILLS + 3)); - skills.put(PrimarySkillType.WOODCUTTING, result.getInt(OFFSET_SKILLS + 4)); - skills.put(PrimarySkillType.UNARMED, result.getInt(OFFSET_SKILLS + 5)); - skills.put(PrimarySkillType.HERBALISM, result.getInt(OFFSET_SKILLS + 6)); - skills.put(PrimarySkillType.EXCAVATION, result.getInt(OFFSET_SKILLS + 7)); - skills.put(PrimarySkillType.ARCHERY, result.getInt(OFFSET_SKILLS + 8)); - skills.put(PrimarySkillType.SWORDS, result.getInt(OFFSET_SKILLS + 9)); - skills.put(PrimarySkillType.AXES, result.getInt(OFFSET_SKILLS + 10)); - skills.put(PrimarySkillType.ACROBATICS, result.getInt(OFFSET_SKILLS + 11)); - skills.put(PrimarySkillType.FISHING, result.getInt(OFFSET_SKILLS + 12)); - skills.put(PrimarySkillType.ALCHEMY, result.getInt(OFFSET_SKILLS + 13)); - skills.put(PrimarySkillType.CROSSBOWS, result.getInt(OFFSET_SKILLS + 14)); - skills.put(PrimarySkillType.TRIDENTS, result.getInt(OFFSET_SKILLS + 15)); - skills.put(PrimarySkillType.MACES, result.getInt(OFFSET_SKILLS + 16)); - - skillsXp.put(PrimarySkillType.TAMING, result.getFloat(OFFSET_XP + 1)); - skillsXp.put(PrimarySkillType.MINING, result.getFloat(OFFSET_XP + 2)); - skillsXp.put(PrimarySkillType.REPAIR, result.getFloat(OFFSET_XP + 3)); - skillsXp.put(PrimarySkillType.WOODCUTTING, result.getFloat(OFFSET_XP + 4)); - skillsXp.put(PrimarySkillType.UNARMED, result.getFloat(OFFSET_XP + 5)); - skillsXp.put(PrimarySkillType.HERBALISM, result.getFloat(OFFSET_XP + 6)); - skillsXp.put(PrimarySkillType.EXCAVATION, result.getFloat(OFFSET_XP + 7)); - skillsXp.put(PrimarySkillType.ARCHERY, result.getFloat(OFFSET_XP + 8)); - skillsXp.put(PrimarySkillType.SWORDS, result.getFloat(OFFSET_XP + 9)); - skillsXp.put(PrimarySkillType.AXES, result.getFloat(OFFSET_XP + 10)); - skillsXp.put(PrimarySkillType.ACROBATICS, result.getFloat(OFFSET_XP + 11)); - skillsXp.put(PrimarySkillType.FISHING, result.getFloat(OFFSET_XP + 12)); - skillsXp.put(PrimarySkillType.ALCHEMY, result.getFloat(OFFSET_XP + 13)); - skillsXp.put(PrimarySkillType.CROSSBOWS, result.getFloat(OFFSET_XP + 14)); - skillsXp.put(PrimarySkillType.TRIDENTS, result.getFloat(OFFSET_XP + 15)); - skillsXp.put(PrimarySkillType.MACES, result.getFloat(OFFSET_XP + 16)); - - // Taming - Unused - result.getInt(OFFSET_DATS + 1) - skillsDATS.put(SuperAbilityType.SUPER_BREAKER, result.getInt(OFFSET_DATS + 2)); - // Repair - Unused - result.getInt(OFFSET_DATS + 3) - skillsDATS.put(SuperAbilityType.TREE_FELLER, result.getInt(OFFSET_DATS + 4)); - skillsDATS.put(SuperAbilityType.BERSERK, result.getInt(OFFSET_DATS + 5)); - skillsDATS.put(SuperAbilityType.GREEN_TERRA, result.getInt(OFFSET_DATS + 6)); - skillsDATS.put(SuperAbilityType.GIGA_DRILL_BREAKER, result.getInt(OFFSET_DATS + 7)); - skillsDATS.put(SuperAbilityType.EXPLOSIVE_SHOT, result.getInt(OFFSET_DATS + 8)); - // Archery - Unused - result.getInt(OFFSET_DATS + 8) - skillsDATS.put(SuperAbilityType.SERRATED_STRIKES, result.getInt(OFFSET_DATS + 9)); - skillsDATS.put(SuperAbilityType.SKULL_SPLITTER, result.getInt(OFFSET_DATS + 10)); - // Acrobatics - Unused - result.getInt(OFFSET_DATS + 11) - skillsDATS.put(SuperAbilityType.BLAST_MINING, result.getInt(OFFSET_DATS + 12)); - uniqueData.put(UniqueDataType.CHIMAERA_WING_DATS, result.getInt(OFFSET_DATS + 13)); - skillsDATS.put(SuperAbilityType.SUPER_SHOTGUN, result.getInt(OFFSET_DATS + 14)); - skillsDATS.put(SuperAbilityType.TRIDENTS_SUPER_ABILITY, result.getInt(OFFSET_DATS + 15)); - skillsDATS.put(SuperAbilityType.MACES_SUPER_ABILITY, result.getInt(OFFSET_DATS + 16)); - - // ORDER IS AS FOLLOWS - // MOB HEALTH BAR - // SCOREBOARD TIPS - // UUID - // USER - - try { - // Mob Health bar is unused, so we add two - // TODO: Why even SELECT the mob health bar? - // Refactor later. - scoreboardTipsShown = result.getInt(OFFSET_OTHER + 2); - } catch (Exception e) { - scoreboardTipsShown = 0; - } - - try { - uuid = UUID.fromString(result.getString(OFFSET_OTHER + 3)); - } catch (Exception e) { - uuid = null; - } - - return new PlayerProfile(playerName, uuid, skills, skillsXp, skillsDATS, - scoreboardTipsShown, uniqueData, null); - } - - private void printErrors(SQLException ex) { - ex.printStackTrace(); - - // logger.severe("SQLException: " + ex.getMessage()); - logger.severe("SQLState: " + ex.getSQLState()); - logger.severe("VendorError: " + ex.getErrorCode()); - - // Handling SQLException chain - SQLException nextException = ex.getNextException(); - while (nextException != null) { - logger.severe("Caused by: " + nextException.getMessage()); - nextException = nextException.getNextException(); - } - } - - - public DatabaseType getDatabaseType() { - return DatabaseType.SQL; - } - private void checkNameUniqueness(final Statement statement) { ResultSet resultSet = null; try { @@ -1437,7 +1553,7 @@ public final class SQLDatabaseManager implements DatabaseManager { + "ADD INDEX `user` (`user`(20) ASC)"); mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.DROP_NAME_UNIQUENESS); } catch (SQLException ex) { - ex.printStackTrace(); + logSQLException(ex); } finally { tryClose(resultSet); } @@ -1460,7 +1576,8 @@ public final class SQLDatabaseManager implements DatabaseManager { try { statement.executeQuery( "SELECT `blast_mining` FROM `" + tablePrefix + "cooldowns` LIMIT 1"); - mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.ADD_BLAST_MINING_COOLDOWN); + mcMMO.getUpgradeManager() + .setUpgradeCompleted(UpgradeType.ADD_BLAST_MINING_COOLDOWN); } catch (SQLException ex) { logger.info("Updating mcMMO MySQL tables for Blast Mining..."); statement.executeUpdate("ALTER TABLE `" + tablePrefix @@ -1468,11 +1585,13 @@ public final class SQLDatabaseManager implements DatabaseManager { } } - private void checkUpgradeAddUniqueChimaeraWing(final Statement statement) throws SQLException { + private void checkUpgradeAddUniqueChimaeraWing(final Statement statement) + throws SQLException { try { statement.executeQuery( "SELECT `chimaera_wing` FROM `" + tablePrefix + "cooldowns` LIMIT 1"); - mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.ADD_UNIQUE_PLAYER_DATA); + mcMMO.getUpgradeManager() + .setUpgradeCompleted(UpgradeType.ADD_UNIQUE_PLAYER_DATA); } catch (SQLException ex) { logger.info("Updating mcMMO MySQL tables for Chimaera Wing..."); statement.executeUpdate("ALTER TABLE `" + tablePrefix @@ -1517,38 +1636,6 @@ public final class SQLDatabaseManager implements DatabaseManager { } } - private void checkUpgradeAddSQLIndexes(final Statement statement) { - ResultSet resultSet = null; - - try { - resultSet = statement.executeQuery( - "SHOW INDEX FROM `" + tablePrefix + "skills` WHERE `Key_name` LIKE 'idx\\_%'"); - resultSet.last(); - - if (resultSet.getRow() != SkillTools.NON_CHILD_SKILLS.size()) { - logger.info("Indexing tables, this may take a while on larger databases"); - - for (PrimarySkillType skill : SkillTools.NON_CHILD_SKILLS) { - String skill_name = skill.name().toLowerCase(Locale.ENGLISH); - - try { - statement.executeUpdate( - "ALTER TABLE `" + tablePrefix + "skills` ADD INDEX `idx_" - + skill_name + "` (`" + skill_name + "`) USING BTREE"); - } catch (SQLException ex) { - // Ignore - } - } - } - - mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.ADD_SQL_INDEXES); - } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(resultSet); - } - } - private void checkUpgradeAddUUIDs(final Statement statement) { ResultSet resultSet = null; @@ -1556,16 +1643,16 @@ public final class SQLDatabaseManager implements DatabaseManager { resultSet = statement.executeQuery("SELECT * FROM `" + tablePrefix + "users` LIMIT 1"); ResultSetMetaData rsmeta = resultSet.getMetaData(); - boolean column_exists = false; + boolean columnExists = false; for (int i = 1; i <= rsmeta.getColumnCount(); i++) { if (rsmeta.getColumnName(i).equalsIgnoreCase("uuid")) { - column_exists = true; + columnExists = true; break; } } - if (!column_exists) { + if (!columnExists) { logger.info("Adding UUIDs to mcMMO MySQL user table..."); statement.executeUpdate("ALTER TABLE `" + tablePrefix + "users` ADD `uuid` varchar(36) NULL DEFAULT NULL"); @@ -1578,48 +1665,12 @@ public final class SQLDatabaseManager implements DatabaseManager { mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.ADD_UUIDS); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(resultSet); } } - private class GetUUIDUpdatesRequired implements Runnable { - public void run() { - massUpdateLock.lock(); - List names = new ArrayList<>(); - Connection connection = null; - Statement statement = null; - ResultSet resultSet = null; - try { - try { - connection = miscPool.getConnection(); - statement = connection.createStatement(); - resultSet = statement.executeQuery( - "SELECT `user` FROM `" + tablePrefix + "users` WHERE `uuid` IS NULL"); - - while (resultSet.next()) { - names.add(resultSet.getString("user")); - } - } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(resultSet); - tryClose(statement); - tryClose(connection); - } - - if (!names.isEmpty()) { - UUIDUpdateAsyncTask updateTask = new UUIDUpdateAsyncTask(mcMMO.p, names); - updateTask.start(); - updateTask.waitUntilFinished(); - } - } finally { - massUpdateLock.unlock(); - } - } - } - private void checkUpgradeDropPartyNames(final Statement statement) { ResultSet resultSet = null; @@ -1627,16 +1678,16 @@ public final class SQLDatabaseManager implements DatabaseManager { resultSet = statement.executeQuery("SELECT * FROM `" + tablePrefix + "users` LIMIT 1"); ResultSetMetaData rsmeta = resultSet.getMetaData(); - boolean column_exists = false; + boolean columnExists = false; for (int i = 1; i <= rsmeta.getColumnCount(); i++) { if (rsmeta.getColumnName(i).equalsIgnoreCase("party")) { - column_exists = true; + columnExists = true; break; } } - if (column_exists) { + if (columnExists) { logger.info("Removing party name from users table..."); statement.executeUpdate( "ALTER TABLE `" + tablePrefix + "users` DROP COLUMN `party`"); @@ -1644,7 +1695,7 @@ public final class SQLDatabaseManager implements DatabaseManager { mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.DROP_SQL_PARTY_NAMES); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(resultSet); } @@ -1660,16 +1711,16 @@ public final class SQLDatabaseManager implements DatabaseManager { resultSet = statement.executeQuery("SELECT * FROM `" + tablePrefix + "skills` LIMIT 1"); ResultSetMetaData rsmeta = resultSet.getMetaData(); - boolean column_exists = false; + boolean columnExists = false; for (int i = 1; i <= rsmeta.getColumnCount(); i++) { if (rsmeta.getColumnName(i).equalsIgnoreCase("total")) { - column_exists = true; + columnExists = true; break; } } - if (!column_exists) { + if (!columnExists) { logger.info("Adding skill total column to skills table..."); statement.executeUpdate("ALTER TABLE `" + tablePrefix + "skills` ADD COLUMN `total` int NOT NULL DEFAULT '0'"); @@ -1682,14 +1733,23 @@ public final class SQLDatabaseManager implements DatabaseManager { mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.ADD_SKILL_TOTAL); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); + try { + connection.rollback(); + } catch (SQLException ignored) { + // best effort + } } finally { - connection.setAutoCommit(true); + try { + connection.setAutoCommit(true); + } catch (SQLException ignored) { + } tryClose(resultSet); tryClose(statement); } } + private void checkUpgradeDropSpout(final Statement statement) { ResultSet resultSet = null; @@ -1697,16 +1757,16 @@ public final class SQLDatabaseManager implements DatabaseManager { resultSet = statement.executeQuery("SELECT * FROM `" + tablePrefix + "huds` LIMIT 1"); ResultSetMetaData rsmeta = resultSet.getMetaData(); - boolean column_exists = false; + boolean columnExists = false; for (int i = 1; i <= rsmeta.getColumnCount(); i++) { if (rsmeta.getColumnName(i).equalsIgnoreCase("hudtype")) { - column_exists = true; + columnExists = true; break; } } - if (column_exists) { + if (columnExists) { logger.info("Removing Spout HUD type from huds table..."); statement.executeUpdate( "ALTER TABLE `" + tablePrefix + "huds` DROP COLUMN `hudtype`"); @@ -1714,67 +1774,58 @@ public final class SQLDatabaseManager implements DatabaseManager { mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.DROP_SPOUT); } catch (SQLException ex) { - printErrors(ex); + logSQLException(ex); } finally { tryClose(resultSet); } } - private int getUserID(final Connection connection, final String playerName, final UUID uuid) { + private int getUserID(final Connection connection, + final String playerName, + final UUID uuid) { if (uuid == null) { return getUserIDByName(connection, playerName); } - if (cachedUserIDs.containsKey(uuid)) { - return cachedUserIDs.get(uuid); + Integer cached = cachedUserIDs.get(uuid); + if (cached != null) { + return cached; } - ResultSet resultSet = null; - PreparedStatement statement = null; + String sql = "SELECT id, `user` FROM " + tablePrefix + + "users WHERE uuid = ? OR (uuid IS NULL AND `user` = ?)"; - try { - statement = connection.prepareStatement("SELECT id, `user` FROM " + tablePrefix - + "users WHERE uuid = ? OR (uuid IS NULL AND `user` = ?)"); + try (PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, uuid.toString()); statement.setString(2, playerName); - resultSet = statement.executeQuery(); - if (resultSet.next()) { - int id = resultSet.getInt("id"); - - cachedUserIDs.put(uuid, id); - - return id; + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + int id = resultSet.getInt("id"); + cachedUserIDs.put(uuid, id); + return id; + } } } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(resultSet); - tryClose(statement); + logSQLException(ex); } return -1; } private int getUserIDByName(final Connection connection, final String playerName) { - ResultSet resultSet = null; - PreparedStatement statement = null; + String sql = "SELECT id, `user` FROM " + tablePrefix + "users WHERE `user` = ?"; - try { - statement = connection.prepareStatement( - "SELECT id, `user` FROM " + tablePrefix + "users WHERE `user` = ?"); + try (PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, playerName); - resultSet = statement.executeQuery(); - if (resultSet.next()) { - - return resultSet.getInt("id"); + try (ResultSet resultSet = statement.executeQuery()) { + if (resultSet.next()) { + return resultSet.getInt("id"); + } } } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(resultSet); - tryClose(statement); + logSQLException(ex); } return -1; @@ -1784,7 +1835,7 @@ public final class SQLDatabaseManager implements DatabaseManager { if (closeable != null) { try { closeable.close(); - } catch (Exception e) { + } catch (Exception ignored) { // Ignore } } @@ -1798,60 +1849,36 @@ public final class SQLDatabaseManager implements DatabaseManager { savePool.close(); } - public enum PoolIdentifier { - MISC, - LOAD, - SAVE - } - public void resetMobHealthSettings() { - PreparedStatement statement = null; - Connection connection = null; + String sql = "UPDATE " + tablePrefix + "huds SET mobhealthbar = ?"; - try { - connection = getConnection(PoolIdentifier.MISC); - statement = connection.prepareStatement( - "UPDATE " + tablePrefix + "huds SET mobhealthbar = ?"); - statement.setString(1, mcMMO.p.getGeneralConfig().getMobHealthbarDefault().toString()); + try (Connection connection = getConnection(PoolIdentifier.MISC); + PreparedStatement statement = connection.prepareStatement(sql)) { + + statement.setString(1, + mcMMO.p.getGeneralConfig().getMobHealthbarDefault().toString()); statement.executeUpdate(); } catch (SQLException ex) { - printErrors(ex); - } finally { - tryClose(statement); - tryClose(connection); + logSQLException(ex); } } private void updateCharacterSet(@NotNull Statement statement) { - //TODO: Could check the tables for being latin1 before executing queries but it seems moot because it is likely the same computational effort - /* - The following columns were set to use latin1 historically (now utf8mb4) - column user in users - column uuid in users - - column mobhealthbar in huds - */ - - //Alter users table logger.info("SQL Converting tables from latin1 to utf8mb4"); - //Update "user" column try { logger.info("Updating user column to new encoding"); statement.executeUpdate(getUpdateUserInUsersTableSQLQuery()); - //Update "uuid" column logger.info("Updating user column to new encoding"); statement.executeUpdate(getUpdateUUIDInUsersTableSQLQuery()); - //Update "mobhealthbar" column logger.info("Updating mobhealthbar column to new encoding"); statement.executeUpdate(getUpdateMobHealthBarInHudsTableSQLQuery()); mcMMO.getUpgradeManager().setUpgradeCompleted(UpgradeType.SQL_CHARSET_UTF8MB4); - } catch (SQLException e) { - e.printStackTrace(); + logSQLException(e); } } @@ -1885,27 +1912,70 @@ public final class SQLDatabaseManager implements DatabaseManager { " COLLATE utf8mb4_unicode_ci;"; } - public void printAllTablesWithColumns(Connection connection) { - try { - DatabaseMetaData metaData = connection.getMetaData(); - String[] types = {"TABLE"}; - ResultSet tables = metaData.getTables(null, null, "%", types); + private void logSQLException(SQLException ex) { + SQLException current = ex; - while (tables.next()) { - String tableName = tables.getString("TABLE_NAME"); - System.out.println("Table: " + tableName); + while (current != null) { + logger.severe("SQLException occurred:"); + logger.severe(" Message: " + current.getMessage()); + logger.severe(" SQLState: " + current.getSQLState()); + logger.severe(" VendorCode: " + current.getErrorCode()); - ResultSet columns = metaData.getColumns(null, null, tableName, "%"); - while (columns.next()) { - String columnName = columns.getString("COLUMN_NAME"); - String columnType = columns.getString("TYPE_NAME"); - System.out.println(" Column: " + columnName + " Type: " + columnType); - } - columns.close(); + StringWriter sw = new StringWriter(); + current.printStackTrace(new PrintWriter(sw)); + logger.severe(sw.toString()); + + current = current.getNextException(); + if (current != null) { + logger.severe("Caused by next SQLException in chain:"); + } + } + } + + public DatabaseType getDatabaseType() { + return DatabaseType.SQL; + } + + public enum PoolIdentifier { + MISC, + LOAD, + SAVE + } + + private class GetUUIDUpdatesRequired implements Runnable { + @Override + public void run() { + massUpdateLock.lock(); + List names = new ArrayList<>(); + Connection connection = null; + Statement statement = null; + ResultSet resultSet = null; + try { + try { + connection = miscPool.getConnection(); + statement = connection.createStatement(); + resultSet = statement.executeQuery( + "SELECT `user` FROM `" + tablePrefix + "users` WHERE `uuid` IS NULL"); + + while (resultSet.next()) { + names.add(resultSet.getString("user")); + } + } catch (SQLException ex) { + logSQLException(ex); + } finally { + tryClose(resultSet); + tryClose(statement); + tryClose(connection); + } + + if (!names.isEmpty()) { + UUIDUpdateAsyncTask updateTask = new UUIDUpdateAsyncTask(mcMMO.p, names); + updateTask.start(); + updateTask.waitUntilFinished(); + } + } finally { + massUpdateLock.unlock(); } - tables.close(); - } catch (SQLException e) { - e.printStackTrace(); } } } diff --git a/src/main/java/com/gmail/nossr50/database/flatfile/FlatFileDataUtil.java b/src/main/java/com/gmail/nossr50/database/flatfile/FlatFileDataUtil.java index d6d1750c1..6e05172cc 100644 --- a/src/main/java/com/gmail/nossr50/database/flatfile/FlatFileDataUtil.java +++ b/src/main/java/com/gmail/nossr50/database/flatfile/FlatFileDataUtil.java @@ -9,6 +9,7 @@ import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_GREEN_ import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_MACES; import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SERRATED_STRIKES; import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SKULL_SPLITTER; +import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SPEARS; import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SUPER_BREAKER; import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_SUPER_SHOTGUN; import static com.gmail.nossr50.database.FlatFileDatabaseManager.COOLDOWN_TREE_FELLER; @@ -24,6 +25,7 @@ import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_HERBALISM; import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_MACES; import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_MINING; import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_REPAIR; +import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_SPEARS; import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_SWORDS; import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TAMING; import static com.gmail.nossr50.database.FlatFileDatabaseManager.EXP_TRIDENTS; @@ -45,6 +47,7 @@ import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_HERBALIS import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_MACES; import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_MINING; import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_REPAIR; +import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_SPEARS; import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_SWORDS; import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TAMING; import static com.gmail.nossr50.database.FlatFileDatabaseManager.SKILLS_TRIDENTS; @@ -114,18 +117,16 @@ public class FlatFileDataUtil { throws IndexOutOfBoundsException { //TODO: Add UUID recovery? Might not even be worth it. return switch (index) { + //We'll keep using this value for legacy compatibility reasons (not sure if needed but don't care) case USERNAME_INDEX -> - LEGACY_INVALID_OLD_USERNAME; //We'll keep using this value for legacy compatibility reasons (not sure if needed but don't care) - //Assumption: Used to be for something, no longer used - //Assumption: Used to be for something, no longer used - //Assumption: Used to be used for something, no longer used + LEGACY_INVALID_OLD_USERNAME; //Assumption: Used to be used for something, no longer used case 2, 3, 23, 33, LEGACY_LAST_LOGIN, HEALTHBAR -> "IGNORED"; case SKILLS_MINING, SKILLS_REPAIR, SKILLS_UNARMED, SKILLS_HERBALISM, SKILLS_EXCAVATION, SKILLS_ARCHERY, SKILLS_SWORDS, SKILLS_AXES, SKILLS_WOODCUTTING, SKILLS_ACROBATICS, SKILLS_TAMING, SKILLS_FISHING, - SKILLS_ALCHEMY, SKILLS_CROSSBOWS, SKILLS_TRIDENTS, SKILLS_MACES -> + SKILLS_ALCHEMY, SKILLS_CROSSBOWS, SKILLS_TRIDENTS, SKILLS_MACES, SKILLS_SPEARS -> String.valueOf(startingLevel); case OVERHAUL_LAST_LOGIN -> String.valueOf(-1L); case COOLDOWN_BERSERK, COOLDOWN_GIGA_DRILL_BREAKER, COOLDOWN_TREE_FELLER, @@ -133,12 +134,12 @@ public class FlatFileDataUtil { COOLDOWN_SERRATED_STRIKES, COOLDOWN_SKULL_SPLITTER, COOLDOWN_SUPER_BREAKER, COOLDOWN_BLAST_MINING, COOLDOWN_SUPER_SHOTGUN, COOLDOWN_TRIDENTS, COOLDOWN_ARCHERY, COOLDOWN_MACES, - SCOREBOARD_TIPS, COOLDOWN_CHIMAERA_WING, + COOLDOWN_SPEARS, SCOREBOARD_TIPS, COOLDOWN_CHIMAERA_WING, EXP_MINING, EXP_WOODCUTTING, EXP_REPAIR, EXP_UNARMED, EXP_HERBALISM, EXP_EXCAVATION, EXP_ARCHERY, EXP_SWORDS, EXP_AXES, EXP_ACROBATICS, EXP_TAMING, EXP_FISHING, EXP_ALCHEMY, EXP_CROSSBOWS, - EXP_TRIDENTS, EXP_MACES -> "0"; + EXP_TRIDENTS, EXP_MACES, EXP_SPEARS -> "0"; case UUID_INDEX -> throw new IndexOutOfBoundsException(); //TODO: Add UUID recovery? Might not even be worth it. default -> throw new IndexOutOfBoundsException(); diff --git a/src/main/java/com/gmail/nossr50/datatypes/database/UpgradeType.java b/src/main/java/com/gmail/nossr50/datatypes/database/UpgradeType.java index 13faa75b7..971c85018 100644 --- a/src/main/java/com/gmail/nossr50/datatypes/database/UpgradeType.java +++ b/src/main/java/com/gmail/nossr50/datatypes/database/UpgradeType.java @@ -3,7 +3,6 @@ package com.gmail.nossr50.datatypes.database; public enum UpgradeType { ADD_FISHING, ADD_BLAST_MINING_COOLDOWN, - ADD_SQL_INDEXES, ADD_MOB_HEALTHBARS, DROP_SQL_PARTY_NAMES, DROP_SPOUT, diff --git a/src/main/java/com/gmail/nossr50/datatypes/player/McMMOPlayer.java b/src/main/java/com/gmail/nossr50/datatypes/player/McMMOPlayer.java index aa12b55f3..69d7d282d 100644 --- a/src/main/java/com/gmail/nossr50/datatypes/player/McMMOPlayer.java +++ b/src/main/java/com/gmail/nossr50/datatypes/player/McMMOPlayer.java @@ -40,6 +40,7 @@ import com.gmail.nossr50.skills.mining.MiningManager; import com.gmail.nossr50.skills.repair.RepairManager; import com.gmail.nossr50.skills.salvage.SalvageManager; import com.gmail.nossr50.skills.smelting.SmeltingManager; +import com.gmail.nossr50.skills.spears.SpearsManager; import com.gmail.nossr50.skills.swords.SwordsManager; import com.gmail.nossr50.skills.taming.TamingManager; import com.gmail.nossr50.skills.tridents.TridentsManager; @@ -63,6 +64,7 @@ import com.gmail.nossr50.util.sounds.SoundType; import java.util.EnumMap; import java.util.Map; import java.util.UUID; +import java.util.logging.Level; import net.kyori.adventure.identity.Identified; import net.kyori.adventure.identity.Identity; import org.bukkit.Bukkit; @@ -171,73 +173,50 @@ public class McMMOPlayer implements Identified { try { initManager(primarySkillType); } catch (InvalidSkillException e) { - e.printStackTrace(); + mcMMO.p.getLogger().log(Level.SEVERE, + "Invalid skill while initializing skill managers for player " + + player.getName() + + ". Contact the plugin developers.", e); } } } //TODO: Add test private void initManager(PrimarySkillType primarySkillType) throws InvalidSkillException { - switch (primarySkillType) { - case ACROBATICS: - skillManagers.put(primarySkillType, new AcrobaticsManager(this)); - break; - case ALCHEMY: - skillManagers.put(primarySkillType, new AlchemyManager(this)); - break; - case ARCHERY: - skillManagers.put(primarySkillType, new ArcheryManager(this)); - break; - case AXES: - skillManagers.put(primarySkillType, new AxesManager(this)); - break; - case CROSSBOWS: - skillManagers.put(primarySkillType, new CrossbowsManager(this)); - break; - case EXCAVATION: - skillManagers.put(primarySkillType, new ExcavationManager(this)); - break; - case FISHING: - skillManagers.put(primarySkillType, new FishingManager(this)); - break; - case HERBALISM: - skillManagers.put(primarySkillType, new HerbalismManager(this)); - break; - case MINING: - skillManagers.put(primarySkillType, new MiningManager(this)); - break; - case REPAIR: - skillManagers.put(primarySkillType, new RepairManager(this)); - break; - case SALVAGE: - skillManagers.put(primarySkillType, new SalvageManager(this)); - break; - case SMELTING: - skillManagers.put(primarySkillType, new SmeltingManager(this)); - break; - case SWORDS: - skillManagers.put(primarySkillType, new SwordsManager(this)); - break; - case TAMING: - skillManagers.put(primarySkillType, new TamingManager(this)); - break; - case TRIDENTS: - skillManagers.put(primarySkillType, new TridentsManager(this)); - break; - case UNARMED: - skillManagers.put(primarySkillType, new UnarmedManager(this)); - break; - case WOODCUTTING: - skillManagers.put(primarySkillType, new WoodcuttingManager(this)); - break; - case MACES: - if (mcMMO.getCompatibilityManager().getMinecraftGameVersion().isAtLeast(1, 21, 0)) { - skillManagers.put(primarySkillType, new MacesManager(this)); - } - break; - default: - throw new InvalidSkillException( - "The skill named has no manager! Contact the devs!"); + final var version = mcMMO.getCompatibilityManager().getMinecraftGameVersion(); + + final SkillManager manager = switch (primarySkillType) { + case ACROBATICS -> new AcrobaticsManager(this); + case ALCHEMY -> new AlchemyManager(this); + case ARCHERY -> new ArcheryManager(this); + case AXES -> new AxesManager(this); + case CROSSBOWS -> new CrossbowsManager(this); + case EXCAVATION -> new ExcavationManager(this); + case FISHING -> new FishingManager(this); + case HERBALISM -> new HerbalismManager(this); + case MINING -> new MiningManager(this); + case REPAIR -> new RepairManager(this); + case SALVAGE -> new SalvageManager(this); + case SMELTING -> new SmeltingManager(this); + case SWORDS -> new SwordsManager(this); + case TAMING -> new TamingManager(this); + case TRIDENTS -> new TridentsManager(this); + case UNARMED -> new UnarmedManager(this); + case WOODCUTTING -> new WoodcuttingManager(this); + + case MACES -> version.isAtLeast(1, 21, 0) + ? new MacesManager(this) + : null; // keep current behavior: no manager on older versions + + case SPEARS -> version.isAtLeast(1, 21, 11) + ? new SpearsManager(this) + : null; // same here + }; + + if (manager != null) { + skillManagers.put(primarySkillType, manager); + } else { + throw new InvalidSkillException("No valid skill manager for skill: " + primarySkillType); } } @@ -369,6 +348,10 @@ public class McMMOPlayer implements Identified { return (SmeltingManager) skillManagers.get(PrimarySkillType.SMELTING); } + public SpearsManager getSpearsManager() { + return (SpearsManager) skillManagers.get(PrimarySkillType.SPEARS); + } + public SwordsManager getSwordsManager() { return (SwordsManager) skillManagers.get(PrimarySkillType.SWORDS); } diff --git a/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java b/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java index c9c2e9d83..1c61af81d 100644 --- a/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java +++ b/src/main/java/com/gmail/nossr50/datatypes/player/PlayerProfile.java @@ -87,7 +87,7 @@ public class PlayerProfile { this.loaded = isLoaded; } - public PlayerProfile(@NotNull String playerName, UUID uuid, boolean isLoaded, int startingLvl) { + public PlayerProfile(@NotNull String playerName, @Nullable UUID uuid, boolean isLoaded, int startingLvl) { this(playerName, uuid, startingLvl); this.loaded = isLoaded; } diff --git a/src/main/java/com/gmail/nossr50/datatypes/skills/PrimarySkillType.java b/src/main/java/com/gmail/nossr50/datatypes/skills/PrimarySkillType.java index a6073fec9..3dd6422ed 100644 --- a/src/main/java/com/gmail/nossr50/datatypes/skills/PrimarySkillType.java +++ b/src/main/java/com/gmail/nossr50/datatypes/skills/PrimarySkillType.java @@ -24,6 +24,7 @@ public enum PrimarySkillType { REPAIR, SALVAGE, SMELTING, + SPEARS, SWORDS, TAMING, TRIDENTS, diff --git a/src/main/java/com/gmail/nossr50/datatypes/skills/SubSkillType.java b/src/main/java/com/gmail/nossr50/datatypes/skills/SubSkillType.java index 210653077..54e319e15 100644 --- a/src/main/java/com/gmail/nossr50/datatypes/skills/SubSkillType.java +++ b/src/main/java/com/gmail/nossr50/datatypes/skills/SubSkillType.java @@ -83,6 +83,11 @@ public enum SubSkillType { SMELTING_SECOND_SMELT, SMELTING_UNDERSTANDING_THE_ART(8), + /* Spears */ + SPEARS_SPEARS_LIMIT_BREAK(10), + SPEARS_MOMENTUM(10), + SPEARS_SPEAR_MASTERY(8), + /* Swords */ SWORDS_COUNTER_ATTACK(1), SWORDS_RUPTURE(4), diff --git a/src/main/java/com/gmail/nossr50/datatypes/skills/SuperAbilityType.java b/src/main/java/com/gmail/nossr50/datatypes/skills/SuperAbilityType.java index 04dd1baea..922b8809f 100644 --- a/src/main/java/com/gmail/nossr50/datatypes/skills/SuperAbilityType.java +++ b/src/main/java/com/gmail/nossr50/datatypes/skills/SuperAbilityType.java @@ -93,6 +93,13 @@ public enum SuperAbilityType { "Placeholder", "Placeholder", "Placeholder"), + SPEARS_SUPER_ABILITY( + "Placeholder", + "Placeholder", + "Placeholder", + "Placeholder", + "Placeholder", + "Placeholder"), /** * Has cooldown - but has to share a skill with Super Breaker, so needs special treatment @@ -216,8 +223,8 @@ public enum SuperAbilityType { case SUPER_BREAKER -> Permissions.superBreaker(player); case TREE_FELLER -> Permissions.treeFeller(player); // TODO: once implemented, return permissions for the following abilities - case EXPLOSIVE_SHOT, TRIDENTS_SUPER_ABILITY, SUPER_SHOTGUN, MACES_SUPER_ABILITY -> - false; + case EXPLOSIVE_SHOT, TRIDENTS_SUPER_ABILITY, SUPER_SHOTGUN, MACES_SUPER_ABILITY, + SPEARS_SUPER_ABILITY -> false; }; } diff --git a/src/main/java/com/gmail/nossr50/listeners/PlayerListener.java b/src/main/java/com/gmail/nossr50/listeners/PlayerListener.java index 85a2eee71..bf24749f4 100644 --- a/src/main/java/com/gmail/nossr50/listeners/PlayerListener.java +++ b/src/main/java/com/gmail/nossr50/listeners/PlayerListener.java @@ -168,6 +168,7 @@ public class PlayerListener implements Listener { if (WorldBlacklist.isWorldBlacklisted(event.getEntity().getWorld())) { return; } + // world guard main flag check if (WorldGuardUtils.isWorldGuardLoaded() && !WorldGuardManager.getInstance() .hasMainFlag((Player) event.getEntity())) { @@ -342,8 +343,8 @@ public class PlayerListener implements Listener { FishingManager fishingManager = UserManager.getPlayer(player).getFishingManager(); switch (event.getState()) { + // CAUGHT_FISH happens for any item caught (including junk and treasure) case CAUGHT_FISH: - //TODO Update to new API once available! Waiting for case CAUGHT_TREASURE if (event.getCaught() != null) { Item fishingCatch = (Item) event.getCaught(); @@ -675,6 +676,10 @@ public class PlayerListener implements Listener { */ @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) public void onPlayerInteractLowest(PlayerInteractEvent event) { + if (event.getAction() == Action.PHYSICAL) { + return; + } + /* WORLD BLACKLIST CHECK */ if (WorldBlacklist.isWorldBlacklisted(event.getPlayer().getWorld())) { return; @@ -817,6 +822,10 @@ public class PlayerListener implements Listener { */ @EventHandler(priority = EventPriority.MONITOR) public void onPlayerInteractMonitor(PlayerInteractEvent event) { + if (event.getAction() == Action.PHYSICAL) { + return; + } + /* WORLD BLACKLIST CHECK */ if (WorldBlacklist.isWorldBlacklisted(event.getPlayer().getWorld())) { return; diff --git a/src/main/java/com/gmail/nossr50/skills/spears/SpearsManager.java b/src/main/java/com/gmail/nossr50/skills/spears/SpearsManager.java new file mode 100644 index 000000000..ff84603c7 --- /dev/null +++ b/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); + } + +} diff --git a/src/main/java/com/gmail/nossr50/util/ItemUtils.java b/src/main/java/com/gmail/nossr50/util/ItemUtils.java index 50138ea68..c118ad960 100644 --- a/src/main/java/com/gmail/nossr50/util/ItemUtils.java +++ b/src/main/java/com/gmail/nossr50/util/ItemUtils.java @@ -166,20 +166,6 @@ public final class ItemUtils { } } - // TODO: Unit tests - public static boolean isCrossbow(@NotNull ItemStack item) { - return mcMMO.getMaterialMapStore().isCrossbow(item.getType().getKey().getKey()); - } - - // TODO: Unit tests - public static boolean isTrident(@NotNull ItemStack item) { - return mcMMO.getMaterialMapStore().isTrident(item.getType().getKey().getKey()); - } - - public static boolean isMace(@NotNull ItemStack item) { - return mcMMO.getMaterialMapStore().isMace(item.getType().getKey().getKey()); - } - public static boolean hasItemInEitherHand(@NotNull Player player, Material material) { return player.getInventory().getItemInMainHand().getType() == material || player.getInventory().getItemInOffHand().getType() == material; @@ -276,6 +262,46 @@ public final class ItemUtils { return null; } + /** + * Checks if the item is a crossbow. + * + * @param item Item to check + * @return true if the item is a crossbow, false otherwise + */ + public static boolean isCrossbow(@NotNull ItemStack item) { + return mcMMO.getMaterialMapStore().isCrossbow(item.getType().getKey().getKey()); + } + + /** + * Checks if the item is a trident. + * + * @param item Item to check + * @return true if the item is a trident, false otherwise + */ + public static boolean isTrident(@NotNull ItemStack item) { + return mcMMO.getMaterialMapStore().isTrident(item.getType().getKey().getKey()); + } + + /** + * Checks if the item is a mace. + * + * @param item Item to check + * @return true if the item is a mace, false otherwise + */ + public static boolean isMace(@NotNull ItemStack item) { + return mcMMO.getMaterialMapStore().isMace(item.getType().getKey().getKey()); + } + + /** + * Checks if the item is a spear. + * @param item Item to check + * + * @return true if the item is a spear, false otherwise + */ + public static boolean isSpear(@NotNull ItemStack item) { + return mcMMO.getMaterialMapStore().isSpear(item.getType().getKey().getKey()); + } + /** * Checks if the item is a sword. * diff --git a/src/main/java/com/gmail/nossr50/util/LogUtils.java b/src/main/java/com/gmail/nossr50/util/LogUtils.java index e9c970465..cba9005aa 100644 --- a/src/main/java/com/gmail/nossr50/util/LogUtils.java +++ b/src/main/java/com/gmail/nossr50/util/LogUtils.java @@ -8,6 +8,7 @@ public class LogUtils { public static final String DEBUG_STR = "[D] "; public static void debug(@NotNull Logger logger, @NotNull String message) { + // Messages here will get filtered based on config settings via LogFilter logger.info(DEBUG_STR + message); } } diff --git a/src/main/java/com/gmail/nossr50/util/MaterialMapStore.java b/src/main/java/com/gmail/nossr50/util/MaterialMapStore.java index e28380a63..55905dd5f 100644 --- a/src/main/java/com/gmail/nossr50/util/MaterialMapStore.java +++ b/src/main/java/com/gmail/nossr50/util/MaterialMapStore.java @@ -51,9 +51,10 @@ public class MaterialMapStore { private final @NotNull HashSet tridents; private final @NotNull HashSet bows; private final @NotNull HashSet crossbows; - private final @NotNull HashSet tools; - private final @NotNull HashSet enchantables; private final @NotNull HashSet maces; + private final @NotNull HashSet spears; + private final @NotNull HashSet enchantables; + private final @NotNull HashSet tools; private final @NotNull HashSet ores; private final @NotNull HashSet intendedToolPickAxe; @@ -95,15 +96,15 @@ public class MaterialMapStore { crossbows = new HashSet<>(); stringTools = new HashSet<>(); prismarineTools = new HashSet<>(); - tools = new HashSet<>(); - swords = new HashSet<>(); axes = new HashSet<>(); pickAxes = new HashSet<>(); shovels = new HashSet<>(); hoes = new HashSet<>(); tridents = new HashSet<>(); + spears = new HashSet<>(); maces = new HashSet<>(); + tools = new HashSet<>(); enchantables = new HashSet<>(); @@ -459,6 +460,7 @@ public class MaterialMapStore { enchantables.addAll(bows); enchantables.addAll(crossbows); enchantables.addAll(maces); + enchantables.addAll(spears); enchantables.add("shears"); enchantables.add("fishing_rod"); @@ -484,6 +486,7 @@ public class MaterialMapStore { fillShovels(); fillTridents(); fillMaces(); + fillSpears(); fillStringTools(); fillPrismarineTools(); fillBows(); @@ -502,6 +505,7 @@ public class MaterialMapStore { tools.addAll(bows); tools.addAll(crossbows); tools.addAll(maces); + tools.addAll(spears); } private void fillBows() { @@ -527,6 +531,16 @@ public class MaterialMapStore { maces.add("mace"); } + private void fillSpears() { + spears.add("wooden_spear"); + spears.add("stone_spear"); + spears.add("copper_spear"); + spears.add("iron_spear"); + spears.add("golden_spear"); + spears.add("diamond_spear"); + spears.add("netherite_spear"); + } + private void fillTridents() { tridents.add("trident"); } @@ -874,6 +888,14 @@ public class MaterialMapStore { return maces.contains(id); } + public boolean isSpear(@NotNull Material material) { + return isSpear(material.getKey().getKey()); + } + + public boolean isSpear(@NotNull String id) { + return spears.contains(id); + } + public boolean isLeatherArmor(@NotNull Material material) { return isLeatherArmor(material.getKey().getKey()); } diff --git a/src/main/java/com/gmail/nossr50/util/commands/CommandRegistrationManager.java b/src/main/java/com/gmail/nossr50/util/commands/CommandRegistrationManager.java index 224aed5ec..90f42349e 100644 --- a/src/main/java/com/gmail/nossr50/util/commands/CommandRegistrationManager.java +++ b/src/main/java/com/gmail/nossr50/util/commands/CommandRegistrationManager.java @@ -41,6 +41,7 @@ import com.gmail.nossr50.commands.skills.MmoInfoCommand; import com.gmail.nossr50.commands.skills.RepairCommand; import com.gmail.nossr50.commands.skills.SalvageCommand; import com.gmail.nossr50.commands.skills.SmeltingCommand; +import com.gmail.nossr50.commands.skills.SpearsCommand; import com.gmail.nossr50.commands.skills.SwordsCommand; import com.gmail.nossr50.commands.skills.TamingCommand; import com.gmail.nossr50.commands.skills.TridentsCommand; @@ -101,6 +102,7 @@ public final class CommandRegistrationManager { case REPAIR -> command.setExecutor(new RepairCommand()); case SALVAGE -> command.setExecutor(new SalvageCommand()); case SMELTING -> command.setExecutor(new SmeltingCommand()); + case SPEARS -> command.setExecutor(new SpearsCommand()); case SWORDS -> command.setExecutor(new SwordsCommand()); case TAMING -> command.setExecutor(new TamingCommand()); case TRIDENTS -> command.setExecutor(new TridentsCommand()); diff --git a/src/main/java/com/gmail/nossr50/util/skills/CombatUtils.java b/src/main/java/com/gmail/nossr50/util/skills/CombatUtils.java index 7eb32d0d7..056a0de9c 100644 --- a/src/main/java/com/gmail/nossr50/util/skills/CombatUtils.java +++ b/src/main/java/com/gmail/nossr50/util/skills/CombatUtils.java @@ -3,6 +3,7 @@ package com.gmail.nossr50.util.skills; import static com.gmail.nossr50.datatypes.experience.XPGainReason.PVP; import static com.gmail.nossr50.util.AttributeMapper.MAPPED_MOVEMENT_SPEED; import static com.gmail.nossr50.util.MobMetadataUtils.hasMobFlag; +import static com.gmail.nossr50.util.Permissions.canUseSubSkill; import static com.gmail.nossr50.util.skills.ProjectileUtils.isCrossbowProjectile; import com.gmail.nossr50.config.experience.ExperienceConfig; @@ -19,6 +20,7 @@ import com.gmail.nossr50.skills.acrobatics.AcrobaticsManager; import com.gmail.nossr50.skills.archery.ArcheryManager; import com.gmail.nossr50.skills.axes.AxesManager; import com.gmail.nossr50.skills.maces.MacesManager; +import com.gmail.nossr50.skills.spears.SpearsManager; import com.gmail.nossr50.skills.swords.SwordsManager; import com.gmail.nossr50.skills.taming.TamingManager; import com.gmail.nossr50.skills.tridents.TridentsManager; @@ -331,6 +333,46 @@ public final class CombatUtils { printFinalDamageDebug(player, event, mmoPlayer); } + private static void processSpearsCombat(@NotNull LivingEntity target, + @NotNull Player player, + @NotNull EntityDamageByEntityEvent event) { + if (event.getCause() == DamageCause.THORNS) { + return; + } + + double boostedDamage = event.getDamage(); + + final McMMOPlayer mmoPlayer = UserManager.getPlayer(player); + + //Make sure the profiles been loaded + if (mmoPlayer == null) { + return; + } + + final SpearsManager spearsManager = mmoPlayer.getSpearsManager(); + + if (canUseSubSkill(player, SubSkillType.SPEARS_SPEAR_MASTERY)) { + boostedDamage += spearsManager.getSpearMasteryBonusDamage() + * mmoPlayer.getAttackStrength(); + } + + // Apply Limit Break DMG + if (canUseLimitBreak(player, target, SubSkillType.SPEARS_SPEARS_LIMIT_BREAK)) { + boostedDamage += (getLimitBreakDamage( + player, target, SubSkillType.SPEARS_SPEARS_LIMIT_BREAK) + * mmoPlayer.getAttackStrength()); + } + + + event.setDamage(boostedDamage); + + // Apply any non-damage effects here + spearsManager.potentiallyApplyMomentum(); + + processCombatXP(mmoPlayer, target, PrimarySkillType.SPEARS); + printFinalDamageDebug(player, event, mmoPlayer); + } + private static void processAxeCombat(@NotNull LivingEntity target, @NotNull Player player, @NotNull EntityDamageByEntityEvent event) { if (event.getCause() == DamageCause.THORNS) { @@ -391,6 +433,11 @@ public final class CombatUtils { double boostedDamage = event.getDamage(); + // TODO: Temporary hack to avoid unintended spear / unarmed interactions + if (ItemUtils.isSpear(player.getInventory().getItemInOffHand())) { + return; + } + final McMMOPlayer mmoPlayer = UserManager.getPlayer(player); //Make sure the profiles been loaded @@ -642,6 +689,15 @@ public final class CombatUtils { .doesPlayerHaveSkillPermission(player, PrimarySkillType.MACES)) { processMacesCombat(target, player, event); } + } else if (ItemUtils.isSpear(heldItem)) { + if (!mcMMO.p.getSkillTools() + .canCombatSkillsTrigger(PrimarySkillType.SPEARS, target)) { + return; + } + if (mcMMO.p.getSkillTools() + .doesPlayerHaveSkillPermission(player, PrimarySkillType.SPEARS)) { + processSpearsCombat(target, player, event); + } } } else if (entityType == EntityType.WOLF) { Wolf wolf = (Wolf) painSource; diff --git a/src/main/java/com/gmail/nossr50/util/skills/SkillTools.java b/src/main/java/com/gmail/nossr50/util/skills/SkillTools.java index e307c0b56..4c14c9bbe 100644 --- a/src/main/java/com/gmail/nossr50/util/skills/SkillTools.java +++ b/src/main/java/com/gmail/nossr50/util/skills/SkillTools.java @@ -1,6 +1,5 @@ package com.gmail.nossr50.util.skills; -import com.gmail.nossr50.api.exceptions.InvalidSkillException; import com.gmail.nossr50.config.experience.ExperienceConfig; import com.gmail.nossr50.datatypes.skills.PrimarySkillType; import com.gmail.nossr50.datatypes.skills.SubSkillType; @@ -18,6 +17,7 @@ import java.util.Collections; import java.util.EnumMap; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; @@ -27,15 +27,16 @@ import org.jetbrains.annotations.VisibleForTesting; public class SkillTools { private final mcMMO pluginRef; + // TODO: Java has immutable types now, switch to those // TODO: Figure out which ones we don't need, this was copy pasted from a diff branch public final @NotNull ImmutableList LOCALIZED_SKILL_NAMES; public final @NotNull ImmutableList FORMATTED_SUBSKILL_NAMES; public final @NotNull ImmutableSet EXACT_SUBSKILL_NAMES; public final @NotNull ImmutableList CHILD_SKILLS; - public final static @NotNull ImmutableList NON_CHILD_SKILLS; - public final static @NotNull ImmutableList SALVAGE_PARENTS; - public final static @NotNull ImmutableList SMELTING_PARENTS; + public static final @NotNull ImmutableList NON_CHILD_SKILLS; + public static final @NotNull ImmutableList SALVAGE_PARENTS; + public static final @NotNull ImmutableList SMELTING_PARENTS; public final @NotNull ImmutableList COMBAT_SKILLS; public final @NotNull ImmutableList GATHERING_SKILLS; public final @NotNull ImmutableList MISC_SKILLS; @@ -44,73 +45,141 @@ public class SkillTools { private final @NotNull ImmutableMap superAbilityParentRelationshipMap; private final @NotNull ImmutableMap> primarySkillChildrenMap; - // The map below is for the super abilities which require readying a tool, its everything except blast mining private final ImmutableMap mainActivatedAbilityChildMap; private final ImmutableMap primarySkillToolMap; static { + // Build NON_CHILD_SKILLS once from the enum values ArrayList tempNonChildSkills = new ArrayList<>(); for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { - if (primarySkillType != PrimarySkillType.SALVAGE - && primarySkillType != PrimarySkillType.SMELTING) { + if (!isChildSkill(primarySkillType)) { tempNonChildSkills.add(primarySkillType); } } - NON_CHILD_SKILLS = ImmutableList.copyOf(tempNonChildSkills); - SALVAGE_PARENTS = ImmutableList.of(PrimarySkillType.REPAIR, PrimarySkillType.FISHING); - SMELTING_PARENTS = ImmutableList.of(PrimarySkillType.MINING, PrimarySkillType.REPAIR); + + SALVAGE_PARENTS = ImmutableList.of( + PrimarySkillType.REPAIR, + PrimarySkillType.FISHING + ); + SMELTING_PARENTS = ImmutableList.of( + PrimarySkillType.MINING, + PrimarySkillType.REPAIR + ); } - public SkillTools(@NotNull mcMMO pluginRef) throws InvalidSkillException { + public SkillTools(@NotNull mcMMO pluginRef) { this.pluginRef = pluginRef; /* * Setup subskill -> parent relationship map */ - EnumMap tempSubParentMap = new EnumMap<>( - SubSkillType.class); - - //Super hacky and disgusting - for (PrimarySkillType primarySkillType1 : PrimarySkillType.values()) { - for (SubSkillType subSkillType : SubSkillType.values()) { - String[] splitSubSkillName = subSkillType.toString().split("_"); - - if (primarySkillType1.toString().equalsIgnoreCase(splitSubSkillName[0])) { - //Parent Skill Found - tempSubParentMap.put(subSkillType, primarySkillType1); - } - } - } - - subSkillParentRelationshipMap = ImmutableMap.copyOf(tempSubParentMap); + this.subSkillParentRelationshipMap = buildSubSkillParentMap(); /* * Setup primary -> (collection) subskill map */ - - EnumMap> tempPrimaryChildMap = new EnumMap<>( - PrimarySkillType.class); - - //Init the empty Hash Sets - for (PrimarySkillType primarySkillType1 : PrimarySkillType.values()) { - tempPrimaryChildMap.put(primarySkillType1, new HashSet<>()); - } - - //Fill in the hash sets - for (SubSkillType subSkillType : SubSkillType.values()) { - PrimarySkillType parentSkill = subSkillParentRelationshipMap.get(subSkillType); - - //Add this subskill as a child - tempPrimaryChildMap.get(parentSkill).add(subSkillType); - } - - primarySkillChildrenMap = ImmutableMap.copyOf(tempPrimaryChildMap); + this.primarySkillChildrenMap = buildPrimarySkillChildrenMap(subSkillParentRelationshipMap); /* * Setup primary -> tooltype map */ - EnumMap tempToolMap = new EnumMap<>(PrimarySkillType.class); + 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()); + } + + @VisibleForTesting + @NotNull + ImmutableMap buildSubSkillParentMap() { + EnumMap tempSubParentMap = + new EnumMap<>(SubSkillType.class); + + // SubSkillType names use a convention: _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; + } + } + } + + return ImmutableMap.copyOf(tempSubParentMap); + } + + @VisibleForTesting + @NotNull + ImmutableMap> buildPrimarySkillChildrenMap( + ImmutableMap subParentMap) { + + EnumMap> tempPrimaryChildMap = + new EnumMap<>(PrimarySkillType.class); + + // Initialize empty sets + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + tempPrimaryChildMap.put(primarySkillType, new HashSet<>()); + } + + // Fill sets + for (SubSkillType subSkillType : SubSkillType.values()) { + PrimarySkillType parentSkill = subParentMap.get(subSkillType); + if (parentSkill != null) { + tempPrimaryChildMap.get(parentSkill).add(subSkillType); + } + } + + return ImmutableMap.copyOf(tempPrimaryChildMap); + } + + @VisibleForTesting + @NotNull + ImmutableMap buildPrimarySkillToolMap() { + EnumMap tempToolMap = + new EnumMap<>(PrimarySkillType.class); tempToolMap.put(PrimarySkillType.AXES, ToolType.AXE); tempToolMap.put(PrimarySkillType.WOODCUTTING, ToolType.AXE); @@ -120,56 +189,76 @@ public class SkillTools { tempToolMap.put(PrimarySkillType.HERBALISM, ToolType.HOE); tempToolMap.put(PrimarySkillType.MINING, ToolType.PICKAXE); - primarySkillToolMap = ImmutableMap.copyOf(tempToolMap); + return ImmutableMap.copyOf(tempToolMap); + } - /* - * Setup ability -> primary map - * Setup primary -> ability map - */ + /** + * Holder for the two super ability maps, so we can build them in one pass. + */ + @VisibleForTesting + record SuperAbilityMaps( + @NotNull ImmutableMap superAbilityParentRelationshipMap, + @NotNull ImmutableMap mainActivatedAbilityChildMap) { + } - EnumMap tempAbilityParentRelationshipMap = new EnumMap<>( - SuperAbilityType.class); - EnumMap tempMainActivatedAbilityChildMap = new EnumMap<>( - PrimarySkillType.class); + @VisibleForTesting + @NotNull + SuperAbilityMaps buildSuperAbilityMaps() { + final Map tempAbilityParentRelationshipMap = + new EnumMap<>(SuperAbilityType.class); + final Map tempMainActivatedAbilityChildMap = + new EnumMap<>(PrimarySkillType.class); for (SuperAbilityType superAbilityType : SuperAbilityType.values()) { - try { - PrimarySkillType parent = getSuperAbilityParent(superAbilityType); - tempAbilityParentRelationshipMap.put(superAbilityType, parent); + final PrimarySkillType parent = getSuperAbilityParent(superAbilityType); + tempAbilityParentRelationshipMap.put(superAbilityType, parent); - if (superAbilityType != SuperAbilityType.BLAST_MINING) { - //This map is used only for abilities that have a tool readying phase, so blast mining is ignored - tempMainActivatedAbilityChildMap.put(parent, superAbilityType); - } - } catch (InvalidSkillException e) { - e.printStackTrace(); + // This map is used only for abilities that have a tool readying phase, + // so Blast Mining is ignored. + if (superAbilityType != SuperAbilityType.BLAST_MINING) { + tempMainActivatedAbilityChildMap.put(parent, superAbilityType); } } - superAbilityParentRelationshipMap = ImmutableMap.copyOf(tempAbilityParentRelationshipMap); - mainActivatedAbilityChildMap = ImmutableMap.copyOf(tempMainActivatedAbilityChildMap); - - /* - * Build child skill and nonchild skill lists - */ + return new SuperAbilityMaps( + ImmutableMap.copyOf(tempAbilityParentRelationshipMap), + ImmutableMap.copyOf(tempMainActivatedAbilityChildMap) + ); + } + @VisibleForTesting + @NotNull + ImmutableList buildChildSkills() { List childSkills = new ArrayList<>(); - for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { if (isChildSkill(primarySkillType)) { childSkills.add(primarySkillType); } } + return ImmutableList.copyOf(childSkills); + } - CHILD_SKILLS = ImmutableList.copyOf(childSkills); + @VisibleForTesting + @NotNull + ImmutableList buildCombatSkills() { + var gameVersion = mcMMO.getCompatibilityManager().getMinecraftGameVersion(); - /* - * Build categorized skill lists - */ - - // We are in a game version with Maces - if (mcMMO.getCompatibilityManager().getMinecraftGameVersion().isAtLeast(1, 21, 0)) { - COMBAT_SKILLS = ImmutableList.of( + if (gameVersion.isAtLeast(1, 21, 11)) { + // We are in a game version with Spears and Maces + return ImmutableList.of( + PrimarySkillType.ARCHERY, + PrimarySkillType.AXES, + PrimarySkillType.CROSSBOWS, + PrimarySkillType.MACES, + PrimarySkillType.SWORDS, + PrimarySkillType.SPEARS, + PrimarySkillType.TAMING, + PrimarySkillType.TRIDENTS, + PrimarySkillType.UNARMED + ); + } else if (gameVersion.isAtLeast(1, 21, 0)) { + // We are in a game version with Maces + return ImmutableList.of( PrimarySkillType.ARCHERY, PrimarySkillType.AXES, PrimarySkillType.CROSSBOWS, @@ -177,42 +266,23 @@ public class SkillTools { PrimarySkillType.SWORDS, PrimarySkillType.TAMING, PrimarySkillType.TRIDENTS, - PrimarySkillType.UNARMED); + PrimarySkillType.UNARMED + ); } else { // No Maces in this version - COMBAT_SKILLS = ImmutableList.of( + return ImmutableList.of( PrimarySkillType.ARCHERY, PrimarySkillType.AXES, PrimarySkillType.CROSSBOWS, PrimarySkillType.SWORDS, PrimarySkillType.TAMING, PrimarySkillType.TRIDENTS, - PrimarySkillType.UNARMED); + PrimarySkillType.UNARMED + ); } - GATHERING_SKILLS = ImmutableList.of( - PrimarySkillType.EXCAVATION, - PrimarySkillType.FISHING, - PrimarySkillType.HERBALISM, - PrimarySkillType.MINING, - PrimarySkillType.WOODCUTTING); - MISC_SKILLS = ImmutableList.of( - PrimarySkillType.ACROBATICS, - PrimarySkillType.ALCHEMY, - PrimarySkillType.REPAIR, - PrimarySkillType.SALVAGE, - PrimarySkillType.SMELTING); - - /* - * Build formatted/localized/etc string lists - */ - - LOCALIZED_SKILL_NAMES = ImmutableList.copyOf(buildLocalizedPrimarySkillNames()); - FORMATTED_SUBSKILL_NAMES = ImmutableList.copyOf(buildFormattedSubSkillNameList()); - EXACT_SUBSKILL_NAMES = ImmutableSet.copyOf(buildExactSubSkillNameList()); } - private @NotNull PrimarySkillType getSuperAbilityParent(SuperAbilityType superAbilityType) - throws InvalidSkillException { + private @NotNull PrimarySkillType getSuperAbilityParent(SuperAbilityType superAbilityType) { return switch (superAbilityType) { case BERSERK -> PrimarySkillType.UNARMED; case GREEN_TERRA -> PrimarySkillType.HERBALISM; @@ -225,11 +295,12 @@ public class SkillTools { case TRIDENTS_SUPER_ABILITY -> PrimarySkillType.TRIDENTS; case EXPLOSIVE_SHOT -> PrimarySkillType.ARCHERY; case MACES_SUPER_ABILITY -> PrimarySkillType.MACES; + case SPEARS_SUPER_ABILITY -> PrimarySkillType.SPEARS; }; } /** - * Makes a list of the "nice" version of sub skill names Used in tab completion mostly + * Makes a list of the "nice" version of sub skill names. Used in tab completion mostly. * * @return a list of formatted sub skill names */ @@ -272,9 +343,12 @@ public class SkillTools { } /** - * Matches a string of a skill to a skill This is NOT case sensitive First it checks the locale - * file and tries to match by the localized name of the skill Then if nothing is found it checks - * against the hard coded "name" of the skill, which is just its name in English + * Matches a string of a skill to a skill. + * This is NOT case-sensitive. + *

+ * First it checks the locale file and tries to match by the localized name of the skill. + * Then if nothing is found it checks against the hard coded "name" of the skill, + * which is just its name in English. * * @param skillName target skill name * @return the matching PrimarySkillType if one is found, otherwise null @@ -282,8 +356,9 @@ public class SkillTools { public PrimarySkillType matchSkill(String skillName) { if (!pluginRef.getGeneralConfig().getLocale().equalsIgnoreCase("en_US")) { for (PrimarySkillType type : PrimarySkillType.values()) { - if (skillName.equalsIgnoreCase(LocaleLoader.getString( - StringUtils.getCapitalized(type.name()) + ".SkillName"))) { + String localized = LocaleLoader.getString( + StringUtils.getCapitalized(type.name()) + ".SkillName"); + if (skillName.equalsIgnoreCase(localized)) { return type; } } @@ -297,15 +372,15 @@ public class SkillTools { if (!skillName.equalsIgnoreCase("all")) { pluginRef.getLogger() - .warning("Invalid mcMMO skill (" + skillName + ")"); //TODO: Localize + .warning("Invalid mcMMO skill (" + skillName + ")"); // TODO: Localize } return null; } /** - * Gets the PrimarySkillStype to which a SubSkillType belongs Return null if it does not belong - * to one.. which should be impossible in most circumstances + * Gets the PrimarySkillType to which a SubSkillType belongs. + * Returns null if it does not belong to one (which should be impossible in most circumstances). * * @param subSkillType target subskill * @return the PrimarySkillType of this SubSkill, null if it doesn't exist @@ -315,8 +390,8 @@ public class SkillTools { } /** - * Gets the PrimarySkillStype to which a SuperAbilityType belongs Return null if it does not - * belong to one.. which should be impossible in most circumstances + * Gets the PrimarySkillType to which a SuperAbilityType belongs. + * Returns null if it does not belong to one (which should be impossible in most circumstances). * * @param superAbilityType target super ability * @return the PrimarySkillType of this SuperAbilityType, null if it doesn't exist @@ -326,16 +401,15 @@ public class SkillTools { } public SuperAbilityType getSuperAbility(PrimarySkillType primarySkillType) { - if (mainActivatedAbilityChildMap.get(primarySkillType) == null) { - return null; - } - return mainActivatedAbilityChildMap.get(primarySkillType); } public boolean isSuperAbilityUnlocked(PrimarySkillType primarySkillType, Player player) { - SuperAbilityType superAbilityType = mcMMO.p.getSkillTools() - .getSuperAbility(primarySkillType); + SuperAbilityType superAbilityType = getSuperAbility(primarySkillType); + if (superAbilityType == null) { + return false; + } + SubSkillType subSkillType = superAbilityType.getSubSkillTypeDefinition(); return RankUtils.hasUnlockedSubskill(player, subSkillType); } @@ -368,7 +442,6 @@ public class SkillTools { return ExperienceConfig.getInstance().getFormulaSkillModifier(primarySkillType); } - // TODO: This is a little "hacky", we probably need to add something to distinguish child skills in the enum, or to use another enum for them public static boolean isChildSkill(PrimarySkillType primarySkillType) { return switch (primarySkillType) { case SALVAGE, SMELTING -> true; @@ -392,8 +465,10 @@ public class SkillTools { } public boolean canCombatSkillsTrigger(PrimarySkillType primarySkillType, Entity target) { - return (target instanceof Player || (target instanceof Tameable - && ((Tameable) target).isTamed())) ? getPVPEnabled(primarySkillType) + boolean isPlayerOrTamed = (target instanceof Player) + || (target instanceof Tameable && ((Tameable) target).isTamed()); + return isPlayerOrTamed + ? getPVPEnabled(primarySkillType) : getPVEEnabled(primarySkillType); } @@ -410,7 +485,7 @@ public class SkillTools { } public int getLevelCap(@NotNull PrimarySkillType primarySkillType) { - return mcMMO.p.getGeneralConfig().getLevelCap(primarySkillType); + return pluginRef.getGeneralConfig().getLevelCap(primarySkillType); } /** @@ -445,17 +520,12 @@ public class SkillTools { } public @NotNull ImmutableList getChildSkillParents( - PrimarySkillType childSkill) - throws IllegalArgumentException { - switch (childSkill) { - case SALVAGE -> { - return SALVAGE_PARENTS; - } - case SMELTING -> { - return SMELTING_PARENTS; - } + PrimarySkillType childSkill) throws IllegalArgumentException { + return switch (childSkill) { + case SALVAGE -> SALVAGE_PARENTS; + case SMELTING -> SMELTING_PARENTS; default -> throw new IllegalArgumentException( "Skill " + childSkill + " is not a child skill"); - } + }; } } diff --git a/src/main/java/com/gmail/nossr50/util/text/TextComponentFactory.java b/src/main/java/com/gmail/nossr50/util/text/TextComponentFactory.java index 16d10ed09..5621ebf09 100644 --- a/src/main/java/com/gmail/nossr50/util/text/TextComponentFactory.java +++ b/src/main/java/com/gmail/nossr50/util/text/TextComponentFactory.java @@ -544,7 +544,25 @@ public class TextComponentFactory { componentBuilder.append(Component.newline()); } + /** + * @deprecated use appendSubSkillTextComponents(Player, List, 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 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 textComponents, PrimarySkillType parentSkill) { for (SubSkillType subSkillType : SubSkillType.values()) { if (subSkillType.getParentSkill() == parentSkill) { diff --git a/src/main/resources/advanced.yml b/src/main/resources/advanced.yml index 4e876fa76..251f7bebc 100644 --- a/src/main/resources/advanced.yml +++ b/src/main/resources/advanced.yml @@ -647,4 +647,19 @@ Skills: Rank_1: 10 Rank_2: 15 Rank_3: 20 - Rank_4: 33 \ No newline at end of file + 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 \ No newline at end of file diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index bb204c3d1..c3706f43a 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -229,6 +229,7 @@ Hardcore: Tridents: false Crossbows: false Maces: false + Spears: false Vampirism: Leech_Percentage: 5.0 Level_Threshold: 0 @@ -249,6 +250,7 @@ Hardcore: Tridents: false Crossbows: false Maces: false + Spears: false # # Settings for SMP Mods @@ -427,6 +429,10 @@ Skills: Enabled_For_PVP: true Enabled_For_PVE: true Level_Cap: 0 + Spears: + Enabled_For_PVP: true + Enabled_For_PVE: true + Level_Cap: 0 Taming: Enabled_For_PVP: true Enabled_For_PVE: true diff --git a/src/main/resources/experience.yml b/src/main/resources/experience.yml index b44672872..4011cd710 100644 --- a/src/main/resources/experience.yml +++ b/src/main/resources/experience.yml @@ -95,6 +95,10 @@ Experience_Bars: Enable: true Color: BLUE BarStyle: SEGMENTED_6 + Spears: + Enable: true + Color: BLUE + BarStyle: SEGMENTED_6 Repair: Enable: true Color: PURPLE @@ -167,6 +171,7 @@ Experience_Formula: # Experience gained will get multiplied by these values. 1.0 by default, 0.5 means half XP gained. This happens right before multiplying the XP by the global multiplier. Skill_Multiplier: + Spears: 1.0 Maces: 1.0 Crossbows: 1.0 Tridents: 1.0 @@ -218,6 +223,7 @@ Diminished_Returns: Crossbows: 20000 Tridents: 20000 Maces: 20000 + Spears: 20000 Time_Interval: 10 @@ -582,6 +588,7 @@ Experience_Values: Taming: Animal_Taming: Camel: 1300 + Camel_Husk: 1300 Sniffer: 1500 Snifflet: 900 Llama: 1200 @@ -600,12 +607,15 @@ Experience_Values: Goat: 250 Axolotl: 600 Frog: 900 + Nautilus: 1700 + Zombie_Nautilus: 1700 Combat: Multiplier: Animals: 1.0 Armadillo: 1.1 Creeper: 4.0 Skeleton: 3.0 + Parched: 2.5 Spider: 2.0 Giant: 4.0 Zombie: 2.0 @@ -683,6 +693,7 @@ Experience_Values: Sniffer: 1.1 Snifflet: 1.1 Camel: 1.2 + Camel_Husk: 1.25 Bogged: 2.0 Breeze: 4.0 Armor_Stand: 0.0 diff --git a/src/main/resources/locale/locale_en_US.properties b/src/main/resources/locale/locale_en_US.properties index ed051f5ad..b99407877 100644 --- a/src/main/resources/locale/locale_en_US.properties +++ b/src/main/resources/locale/locale_en_US.properties @@ -27,6 +27,7 @@ JSON.Salvage=Salvage JSON.Swords=Swords JSON.Taming=Taming JSON.Tridents=Tridents +JSON.Spears=Spears JSON.Maces=Maces JSON.Unarmed=Unarmed JSON.Woodcutting=Woodcutting @@ -98,6 +99,7 @@ Overhaul.Name.Smelting=Smelting Overhaul.Name.Swords=Swords Overhaul.Name.Taming=Taming Overhaul.Name.Tridents=Tridents +Overhaul.Name.Spears=Spears Overhaul.Name.Maces=Maces Overhaul.Name.Unarmed=Unarmed Overhaul.Name.Woodcutting=Woodcutting @@ -125,6 +127,7 @@ XPBar.Smelting=Smelting Lv.&6{0} XPBar.Swords=Swords Lv.&6{0} XPBar.Taming=Taming Lv.&6{0} XPBar.Tridents=Tridents Lv.&6{0} +XPBar.Spears=Spears Lv.&6{0} XPBar.Maces=Maces Lv.&6{0} XPBar.Unarmed=Unarmed Lv.&6{0} XPBar.Woodcutting=Woodcutting Lv.&6{0} @@ -474,6 +477,24 @@ Maces.SubSkill.Cripple.Stat=Cripple Chance Maces.SubSkill.Cripple.Stat.Extra=[[DARK_AQUA]]Cripple Duration: &e{0}s&a vs Players, &e{1}s&a vs Mobs. Maces.Listener=Maces: +#SPEARS +Spears.SkillName=SPEARS +Spears.Ability.Lower=&7You lower your spear. +Spears.Ability.Ready=&3You &6ready&3 your spear. +Spears.SubSkill.SpearsLimitBreak.Name=Spears Limit Break +Spears.SubSkill.SpearsLimitBreak.Description=Breaking your limits. Increased damage against tough opponents. Intended for PVP, up to server settings for whether it will boost damage in PVE. +Spears.SubSkill.SpearsLimitBreak.Stat=Limit Break Max DMG +Spears.SubSkill.SpearAbility.Name=WIP +Spears.SubSkill.Momentum.Name=Momentum +Spears.SubSkill.Momentum.Description=Adds a chance to increase movement speed for a short duration when attacking. +Spears.SubSkill.Momentum.Stat=Momentum Chance +Spears.SubSkill.Momentum.Stat.Extra=[[DARK_AQUA]]Momentum Duration: &e{0}s +Spears.SubSkill.Momentum.Activated=MOMENTUM ACTIVATED! +Spears.SubSkill.SpearMastery.Name=Spear Mastery +Spears.SubSkill.SpearMastery.Description=Adds bonus damage to your attacks. +Spears.SubSkill.SpearMastery.Stat=Spear Mastery Bonus DMG +Spears.Listener=Spears: + #SWORDS Swords.Ability.Lower=&7You lower your sword. Swords.Ability.Ready=&3You &6ready&3 your Sword. @@ -913,6 +934,7 @@ Commands.XPGain.Repair=Repairing Commands.XPGain.Swords=Attacking Monsters Commands.XPGain.Taming=Animal Taming, or combat w/ your wolves Commands.XPGain.Tridents=Attacking Monsters +Commands.XPGain.Spears=Attacking Monsters Commands.XPGain.Unarmed=Attacking Monsters Commands.XPGain.Woodcutting=Chopping down trees Commands.XPGain=&8XP GAIN: &f{0} @@ -1047,12 +1069,12 @@ Guides.Woodcutting.Section.1=&3How does Tree Feller work?\n&eTree Feller is an a Guides.Woodcutting.Section.2=&3How does Leaf Blower work?\n&eLeaf Blower is a passive ability that will cause leaf\n&eblocks to break instantly when hit with an axe. By default,\nðis ability unlocks at level 100. Guides.Woodcutting.Section.3=&3How do Double Drops work?\n&eThis passive ability gives you a chance to obtain an extra\n&eblock for every log you chop. # Crossbows -Guides.Crossbows.Section.0=&3About Crossbows:\n&eCrossbows is all about shooting with your crossbow.\n\n&3XP GAIN:\n&eXP is gained whenever you shoot mobs with a crossbow.\nThis is a WIP skill and more information will be added soon. +Guides.Crossbows.Section.0=&3About Crossbows:\n&eCrossbows is all about shooting with your crossbow.\n\n&3XP GAIN:\n&eXP is gained whenever you shoot mobs with a crossbow. Guides.Crossbows.Section.1=&3How does Trickshot work?\n&eTrickshot is an passive ability, you shoot your bolts at a shallow angle with a crossbow to attempt a Trickshot. This will cause the arrow to ricochet off of blocks and potentially hit a target. The number of potential bounces from a ricochet depend on the rank of Trickshot. # Tridents -Guides.Tridents.Section.0=&3About Tridents:\n&eTridents skill involves impaling foes with your trident.\n\n&3XP GAIN:\n&eXP is gained whenever you hit mobs with a trident.\nThis is a WIP skill and more information will be added soon. -Guides.Maces.Section.0=&3About Maces:\n&eMaces is all about smashing your foes with a mace.\n\n&3XP GAIN:\n&eXP is gained whenever you hit mobs with a mace.\nThis is a WIP skill and more information will be added soon. - +Guides.Tridents.Section.0=&3About Tridents:\n&eTridents skill involves impaling foes with your trident.\n\n&3XP GAIN:\n&eXP is gained whenever you hit mobs with a trident. +Guides.Maces.Section.0=&3About Maces:\n&eMaces is all about smashing your foes with a mace.\n\n&3XP GAIN:\n&eXP is gained whenever you hit mobs with a mace. +Guides.Spears.Section.0=&3About Spears:\n&eSpears is all about impaling your foes with a spear.\n\n&3XP GAIN:\n&eXP is gained whenever you hit mobs with a spear. #INSPECT Inspect.Offline= &cYou do not have permission to inspect offline players! Inspect.OfflineStats=mcMMO Stats for Offline Player &e{0} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index eca3bbe9b..704677f22 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,20 +1,20 @@ name: mcMMO version: ${project.version} description: > - The goal of mcMMO is to take core Minecraft game mechanics and expand them into - add an extensive and quality RPG experience. Everything in mcMMO has been carefully - thought out and is constantly being improved upon. Currently, mcMMO adds thirteen - unique skills to train and level in. Each of these skills is highly customizable - through our configuration files, allowing server admins to tweak mcMMO to best suit - the needs of his or her server. Know that the mcMMO team is dedicated to providing - an ever-evolving experience, and that we carefully read all feedback and bug reports - in order to evaluate and balance the mechanics of mcMMO in every update. + The goal of mcMMO is to take core Minecraft game mechanics and expand them into + add an extensive and quality RPG experience. Everything in mcMMO has been carefully + thought out and is constantly being improved upon. Currently, mcMMO adds thirteen + unique skills to train and level in. Each of these skills is highly customizable + through our configuration files, allowing server admins to tweak mcMMO to best suit + the needs of his or her server. Know that the mcMMO team is dedicated to providing + an ever-evolving experience, and that we carefully read all feedback and bug reports + in order to evaluate and balance the mechanics of mcMMO in every update. author: nossr50 -authors: [GJ, NuclearW, bm01, Glitchfinder, TfT_02, t00thpick1, Riking, electronicboy, kashike] +authors: [ GJ, NuclearW, bm01, Glitchfinder, TfT_02, t00thpick1, Riking, electronicboy, kashike ] website: https://www.mcmmo.org main: com.gmail.nossr50.mcMMO -softdepend: [WorldGuard, CombatTag, HealthBar, PlaceholderAPI, ProtocolLib] +softdepend: [ WorldGuard, CombatTag, HealthBar, PlaceholderAPI, ProtocolLib ] load: POSTWORLD folia-supported: true api-version: 1.13 @@ -26,14 +26,14 @@ commands: mmocompat: description: Information about the server and whether its considered fully compatible or running in compatibility mode mmodebug: - aliases: [mcmmodebugmode] + aliases: [ mcmmodebugmode ] description: Toggles a debug mode which will print useful information to chat mmoinfo: - aliases: [mcinfo] + aliases: [ mcinfo ] description: Info pages for mcMMO permission: mcmmo.commands.mmoinfo xprate: - aliases: [mcxprate] + aliases: [ mcxprate ] description: Modify the xp rate or start an event permission: mcmmo.commands.xprate mcmmo: @@ -59,7 +59,7 @@ commands: permission: mcmmo.commands.mcrefresh mccooldown: description: Show the cooldowns on all your mcMMO abilities - aliases: [mccooldowns] + aliases: [ mccooldowns ] permission: mcmmo.commands.mccooldown mcchatspy: description: Toggle mcMMO Party Chat spying on/off @@ -68,7 +68,7 @@ commands: description: Toggle mcMMO god-mode on/off permission: mcmmo.commands.mcgod mcstats: - aliases: [stats] + aliases: [ stats ] description: Shows your mcMMO stats and xp permission: mcmmo.commands.mcstats mcremove: @@ -84,7 +84,7 @@ commands: description: Create/join a party permission: mcmmo.commands.party inspect: - aliases: [mcinspect, mmoinspect] + aliases: [ mcinspect, mmoinspect ] description: View detailed mcMMO info on another player permission: mcmmo.commands.inspect mmoshowdb: @@ -94,7 +94,7 @@ commands: description: Convert between different database and formula types permission: mcmmo.commands.mcconvert partychat: - aliases: [pc, p] + aliases: [ pc, p ] description: Toggle Party chat or send party chat messages permission: mcmmo.chat.partychat skillreset: @@ -145,6 +145,9 @@ commands: smelting: description: Detailed mcMMO skill info permission: mcmmo.commands.smelting + spears: + description: Detailed mcMMO skill info + permission: mcmmo.commands.spears alchemy: description: Detailed mcMMO skill info permission: mcmmo.commands.alchemy @@ -157,24 +160,24 @@ commands: mmopower: description: Shows skill mastery and power level info permission: mcmmo.commands.mmopower - aliases: [mmopowerlevel, powerlevel] + aliases: [ mmopowerlevel, powerlevel ] adminchat: - aliases: [ac, a] + aliases: [ ac, a ] description: Toggle Admin chat or send admin chat messages permission: mcmmo.chat.adminchat mcpurge: description: Purge users with 0 powerlevel and/or who haven't connected in several months from the server DB. permission: mcmmo.commands.mcpurge mcnotify: - aliases: [notify] + aliases: [ notify ] description: Toggle mcMMO abilities chat display notifications on/off permission: mcmmo.commands.mcnotify mcscoreboard: - aliases: [mcsb] + aliases: [ mcsb ] description: Manage your mcMMO Scoreboard permission: mcmmo.commands.mcscoreboard mcmmoreloadlocale: - aliases: [mcreloadlocale] + aliases: [ mcreloadlocale ] description: Reloads locale permission: mcmmo.commands.reloadlocale permissions: @@ -237,6 +240,7 @@ permissions: mcmmo.ability.repair.all: true mcmmo.ability.salvage.all: true mcmmo.ability.smelting.all: true + mcmmo.ability.spears.all: true mcmmo.ability.swords.all: true mcmmo.ability.taming.all: true mcmmo.ability.tridents.all: true @@ -320,19 +324,19 @@ permissions: mcmmo.ability.axes.skullsplitter: description: Allows access to the Skull Splitter ability mcmmo.ability.crossbows.*: - description: Allows access to all Crossbows abilities - children: + description: Allows access to all Crossbows abilities + children: mcmmo.ability.crossbows.all: true mcmmo.ability.crossbows.all: - description: Allows access to all Crossbows abilities - children: - mcmmo.ability.crossbows.trickshot: true - mcmmo.ability.crossbows.poweredshot: true - mcmmo.ability.crossbows.crossbowslimitbreak: true + description: Allows access to all Crossbows abilities + children: + mcmmo.ability.crossbows.trickshot: true + mcmmo.ability.crossbows.poweredshot: true + mcmmo.ability.crossbows.crossbowslimitbreak: true mcmmo.ability.crossbows.crossbowslimitbreak: - description: Adds damage to crossbows + description: Adds damage to crossbows mcmmo.ability.crossbows.trickshot: - description: Allows access to the Trick Shot ability + description: Allows access to the Trick Shot ability mcmmo.ability.crossbows.poweredshot: description: Allows access to the Powered Shot ability mcmmo.ability.excavation.*: @@ -646,6 +650,23 @@ permissions: description: Allows access to the Second Smelt ability mcmmo.ability.smelting.vanillaxpboost: description: Allows vanilla XP boost from Smelting + mcmmo.ability.spears.*: + default: false + description: Allows access to all Spear abilities + children: + mcmmo.ability.spears.all: true + mcmmo.ability.spears.all: + description: Allows access to all Spear abilities + children: + mcmmo.ability.spears.spearslimitbreak: true + mcmmo.ability.spears.momentum: true + mcmmo.ability.spears.spearmastery: true + mcmmo.ability.spears.spearslimitbreak: + description: Adds damage to spears + mcmmo.ability.spears.momentum: + description: Allows access to the Spear Momentum ability + mcmmo.ability.spears.spearmastery: + description: Allows access to the Spear Mastery ability mcmmo.ability.swords.*: default: false description: Allows access to all Swords abilities @@ -885,6 +906,7 @@ permissions: mcmmo.commands.repair: true mcmmo.commands.salvage: true mcmmo.commands.smelting: true + mcmmo.commands.spears: true mcmmo.commands.swords: true mcmmo.commands.taming: true mcmmo.commands.unarmed: true @@ -898,7 +920,7 @@ permissions: mcmmo.commands.addxp: true mcmmo.commands.addxp.others: true mcmmo.commands.defaults: true -# mcmmo.commands.hardcore.all: true + # mcmmo.commands.hardcore.all: true mcmmo.commands.inspect.far: true mcmmo.commands.inspect.hidden: true mcmmo.commands.mcability.others: true @@ -918,7 +940,7 @@ permissions: mcmmo.commands.ptp.world.all: true mcmmo.commands.reloadlocale: true mcmmo.commands.skillreset.all: true -# mcmmo.commands.vampirism.all: true + # mcmmo.commands.vampirism.all: true mcmmo.commands.xprate.all: true mcmmo.commands.acrobatics: description: Allows access to the acrobatics command @@ -1058,6 +1080,7 @@ permissions: mcmmo.commands.mctop.repair: true mcmmo.commands.mctop.salvage: true mcmmo.commands.mctop.smelting: true + mcmmo.commands.mctop.spears: true mcmmo.commands.mctop.swords: true mcmmo.commands.mctop.taming: true mcmmo.commands.mctop.tridents: true @@ -1091,6 +1114,8 @@ permissions: description: Allows access to the mctop command for salvage mcmmo.commands.mctop.smelting: description: Allows access to the mctop command for smelting + mcmmo.commands.mctop.spears: + description: Allows access to the mctop command for spears mcmmo.commands.mctop.swords: description: Allows access to the mctop command for swords mcmmo.commands.mctop.taming: @@ -1239,6 +1264,7 @@ permissions: mcmmo.commands.skillreset.repair: true mcmmo.commands.skillreset.salvage: true mcmmo.commands.skillreset.smelting: true + mcmmo.commands.skillreset.spears: true mcmmo.commands.skillreset.swords: true mcmmo.commands.skillreset.taming: true mcmmo.commands.skillreset.unarmed: true @@ -1268,6 +1294,8 @@ permissions: description: Allows access to the skillreset command for crossbows mcmmo.commands.skillreset.tridents: description: Allows access to the skillreset command for tridents + mcmmo.commands.skillreset.spears: + description: Allows access to the skillreset command for spears mcmmo.commands.skillreset.maces: description: Allows access to the skillreset command for maces mcmmo.commands.skillreset.others.*: @@ -1290,6 +1318,7 @@ permissions: mcmmo.commands.skillreset.others.repair: true mcmmo.commands.skillreset.others.salvage: true mcmmo.commands.skillreset.others.smelting: true + mcmmo.commands.skillreset.others.spears: true mcmmo.commands.skillreset.others.swords: true mcmmo.commands.skillreset.others.taming: true mcmmo.commands.skillreset.others.unarmed: true @@ -1321,6 +1350,8 @@ permissions: description: Allows access to the skillreset command for salvage for other players mcmmo.commands.skillreset.others.smelting: description: Allows access to the skillreset command for smelting for other players + mcmmo.commands.skillreset.others.spears: + description: Allows access to the skillreset command for spears for other players mcmmo.commands.skillreset.others.swords: description: Allows access to the skillreset command for swords for other players mcmmo.commands.skillreset.others.taming: @@ -1406,7 +1437,7 @@ permissions: default: false description: implies access to all mcmmo perks children: - mcmmo.perks.all: true + mcmmo.perks.all: true mcmmo.perks.all: default: false description: implies access to all mcmmo perks @@ -1497,6 +1528,7 @@ permissions: mcmmo.perks.lucky.repair: true mcmmo.perks.lucky.salvage: true mcmmo.perks.lucky.smelting: true + mcmmo.perks.lucky.spears: true mcmmo.perks.lucky.swords: true mcmmo.perks.lucky.taming: true mcmmo.perks.lucky.unarmed: true @@ -1539,6 +1571,9 @@ permissions: mcmmo.perks.lucky.salvage: default: false description: Gives Salvage abilities & skills a 33.3% better chance to activate. + mcmmo.perks.lucky.spears: + default: false + description: Gives Spears abilities & skills a 33.3% better chance to activate. mcmmo.perks.lucky.smelting: default: false description: Gives Smelting abilities & skills a 33.3% better chance to activate. @@ -1600,6 +1635,7 @@ permissions: mcmmo.perks.xp.150percentboost.mining: true mcmmo.perks.xp.150percentboost.repair: true mcmmo.perks.xp.150percentboost.smelting: true + mcmmo.perks.xp.150percentboost.spears: true mcmmo.perks.xp.150percentboost.swords: true mcmmo.perks.xp.150percentboost.taming: true mcmmo.perks.xp.150percentboost.tridents: true @@ -1641,6 +1677,9 @@ permissions: mcmmo.perks.xp.150percentboost.smelting: default: false description: Multiplies incoming Smelting XP by 2.5 + mcmmo.perks.xp.150percentboost.spears: + default: false + description: Multiplies incoming Spears XP by 2.5 mcmmo.perks.xp.150percentboost.swords: default: false description: Multiplies incoming Swords XP by 2.5 @@ -1682,6 +1721,7 @@ permissions: mcmmo.perks.xp.50percentboost.mining: true mcmmo.perks.xp.50percentboost.repair: true mcmmo.perks.xp.50percentboost.smelting: true + mcmmo.perks.xp.50percentboost.spears: true mcmmo.perks.xp.50percentboost.swords: true mcmmo.perks.xp.50percentboost.taming: true mcmmo.perks.xp.50percentboost.tridents: true @@ -1720,6 +1760,9 @@ permissions: mcmmo.perks.xp.50percentboost.repair: default: false description: Multiplies incoming Repair XP by 1.5 + mcmmo.perks.xp.50percentboost.spears: + default: false + description: Multiplies incoming Spears XP by 1.5 mcmmo.perks.xp.50percentboost.smelting: default: false description: Multiplies incoming Smelting XP by 1.5 @@ -1739,87 +1782,91 @@ permissions: default: false description: Multiplies incoming Woodcutting XP by 1.5 mcmmo.perks.xp.25percentboost.*: - default: false - description: Multiplies incoming XP by 1.25 - children: - mcmmo.perks.xp.25percentboost.all: true - mcmmo.perks.xp.25percentboost: default: false description: Multiplies incoming XP by 1.25 children: - mcmmo.perks.xp.25percentboost.all: true - mcmmo.perks.xp.25percentboost.all: - default: false - description: Multiplies incoming XP by 1.25 - children: - mcmmo.perks.xp.25percentboost.acrobatics: true - mcmmo.perks.xp.25percentboost.alchemy: true - mcmmo.perks.xp.25percentboost.archery: true - mcmmo.perks.xp.25percentboost.axes: true - mcmmo.perks.xp.25percentboost.crossbows: true - mcmmo.perks.xp.25percentboost.excavation: true - mcmmo.perks.xp.25percentboost.fishing: true - mcmmo.perks.xp.25percentboost.herbalism: true - mcmmo.perks.xp.25percentboost.maces: true - mcmmo.perks.xp.25percentboost.mining: true - mcmmo.perks.xp.25percentboost.repair: true - mcmmo.perks.xp.25percentboost.smelting: true - mcmmo.perks.xp.25percentboost.swords: true - mcmmo.perks.xp.25percentboost.taming: true - mcmmo.perks.xp.25percentboost.tridents: true - mcmmo.perks.xp.25percentboost.unarmed: true - mcmmo.perks.xp.25percentboost.woodcutting: true - mcmmo.perks.xp.25percentboost.acrobatics: - default: false - description: Multiplies incoming Acrobatics XP by 1.25 - mcmmo.perks.xp.25percentboost.alchemy: - default: false - description: Multiplies incoming Acrobatics XP by 1.25 - mcmmo.perks.xp.25percentboost.archery: - default: false - description: Multiplies incoming Archery XP by 1.25 - mcmmo.perks.xp.25percentboost.axes: - default: false - description: Multiplies incoming Axes XP by 1.25 - mcmmo.perks.xp.25percentboost.crossbows: - default: false - description: Multiplies incoming Crossbows XP by 1.25 - mcmmo.perks.xp.25percentboost.excavation: - default: false - description: Multiplies incoming Excavation XP by 1.25 - mcmmo.perks.xp.25percentboost.fishing: - default: false - description: Multiplies incoming Fishing XP by 1.25 - mcmmo.perks.xp.25percentboost.herbalism: - default: false - description: Multiplies incoming Herbalism XP by 1.25 - mcmmo.perks.xp.25percentboost.maces: - default: false - description: Multiplies incoming Maces XP by 1.25 - mcmmo.perks.xp.25percentboost.mining: - default: false - description: Multiplies incoming Mining XP by 1.25 - mcmmo.perks.xp.25percentboost.repair: - default: false - description: Multiplies incoming Repair XP by 1.25 - mcmmo.perks.xp.25percentboost.smelting: - default: false - description: Multiplies incoming Smelting XP by 1.25 - mcmmo.perks.xp.25percentboost.swords: - default: false - description: Multiplies incoming Swords XP by 1.25 - mcmmo.perks.xp.25percentboost.taming: - default: false - description: Multiplies incoming Taming XP by 1.25 - mcmmo.perks.xp.25percentboost.tridents: - default: false - description: Multiplies incoming Tridents XP by 1.25 - mcmmo.perks.xp.25percentboost.unarmed: - default: false - description: Multiplies incoming Unarmed XP by 1.5 - mcmmo.perks.xp.25percentboost.woodcutting: - default: false - description: Multiplies incoming Woodcutting XP by 1.25 + mcmmo.perks.xp.25percentboost.all: true + mcmmo.perks.xp.25percentboost: + default: false + description: Multiplies incoming XP by 1.25 + children: + mcmmo.perks.xp.25percentboost.all: true + mcmmo.perks.xp.25percentboost.all: + default: false + description: Multiplies incoming XP by 1.25 + children: + mcmmo.perks.xp.25percentboost.acrobatics: true + mcmmo.perks.xp.25percentboost.alchemy: true + mcmmo.perks.xp.25percentboost.archery: true + mcmmo.perks.xp.25percentboost.axes: true + mcmmo.perks.xp.25percentboost.crossbows: true + mcmmo.perks.xp.25percentboost.excavation: true + mcmmo.perks.xp.25percentboost.fishing: true + mcmmo.perks.xp.25percentboost.herbalism: true + mcmmo.perks.xp.25percentboost.maces: true + mcmmo.perks.xp.25percentboost.mining: true + mcmmo.perks.xp.25percentboost.repair: true + mcmmo.perks.xp.25percentboost.smelting: true + mcmmo.perks.xp.25percentboost.spears: true + mcmmo.perks.xp.25percentboost.swords: true + mcmmo.perks.xp.25percentboost.taming: true + mcmmo.perks.xp.25percentboost.tridents: true + mcmmo.perks.xp.25percentboost.unarmed: true + mcmmo.perks.xp.25percentboost.woodcutting: true + mcmmo.perks.xp.25percentboost.acrobatics: + default: false + description: Multiplies incoming Acrobatics XP by 1.25 + mcmmo.perks.xp.25percentboost.alchemy: + default: false + description: Multiplies incoming Acrobatics XP by 1.25 + mcmmo.perks.xp.25percentboost.archery: + default: false + description: Multiplies incoming Archery XP by 1.25 + mcmmo.perks.xp.25percentboost.axes: + default: false + description: Multiplies incoming Axes XP by 1.25 + mcmmo.perks.xp.25percentboost.crossbows: + default: false + description: Multiplies incoming Crossbows XP by 1.25 + mcmmo.perks.xp.25percentboost.excavation: + default: false + description: Multiplies incoming Excavation XP by 1.25 + mcmmo.perks.xp.25percentboost.fishing: + default: false + description: Multiplies incoming Fishing XP by 1.25 + mcmmo.perks.xp.25percentboost.herbalism: + default: false + description: Multiplies incoming Herbalism XP by 1.25 + mcmmo.perks.xp.25percentboost.maces: + default: false + description: Multiplies incoming Maces XP by 1.25 + mcmmo.perks.xp.25percentboost.mining: + default: false + description: Multiplies incoming Mining XP by 1.25 + mcmmo.perks.xp.25percentboost.repair: + default: false + description: Multiplies incoming Repair XP by 1.25 + mcmmo.perks.xp.25percentboost.smelting: + default: false + description: Multiplies incoming Smelting XP by 1.25 + mcmmo.perks.xp.25percentboost.spears: + default: false + description: Multiplies incoming Spears XP by 1.25 + mcmmo.perks.xp.25percentboost.swords: + default: false + description: Multiplies incoming Swords XP by 1.25 + mcmmo.perks.xp.25percentboost.taming: + default: false + description: Multiplies incoming Taming XP by 1.25 + mcmmo.perks.xp.25percentboost.tridents: + default: false + description: Multiplies incoming Tridents XP by 1.25 + mcmmo.perks.xp.25percentboost.unarmed: + default: false + description: Multiplies incoming Unarmed XP by 1.5 + mcmmo.perks.xp.25percentboost.woodcutting: + default: false + description: Multiplies incoming Woodcutting XP by 1.25 mcmmo.perks.xp.10percentboost.*: default: false description: Multiplies incoming XP by 1.1 @@ -1846,6 +1893,7 @@ permissions: mcmmo.perks.xp.10percentboost.mining: true mcmmo.perks.xp.10percentboost.repair: true mcmmo.perks.xp.10percentboost.smelting: true + mcmmo.perks.xp.10percentboost.spears: true mcmmo.perks.xp.10percentboost.swords: true mcmmo.perks.xp.10percentboost.taming: true mcmmo.perks.xp.10percentboost.tridents: true @@ -1884,6 +1932,9 @@ permissions: mcmmo.perks.xp.10percentboost.repair: default: false description: Multiplies incoming Repair XP by 1.1 + mcmmo.perks.xp.10percentboost.spears: + default: false + description: Multiplies incoming Spears XP by 1.1 mcmmo.perks.xp.10percentboost.smelting: default: false description: Multiplies incoming Smelting XP by 1.1 @@ -1928,6 +1979,7 @@ permissions: mcmmo.perks.xp.customboost.mining: true mcmmo.perks.xp.customboost.repair: true mcmmo.perks.xp.customboost.smelting: true + mcmmo.perks.xp.customboost.spears: true mcmmo.perks.xp.customboost.swords: true mcmmo.perks.xp.customboost.taming: true mcmmo.perks.xp.customboost.tridents: true @@ -1966,6 +2018,9 @@ permissions: mcmmo.perks.xp.customboost.repair: default: false description: Multiplies incoming Repair XP by the boost amount defined in the experience config + mcmmo.perks.xp.customboost.spears: + default: false + description: Multiplies incoming Smelting XP by the boost amount defined in the experience config mcmmo.perks.xp.customboost.smelting: default: false description: Multiplies incoming Smelting XP by the boost amount defined in the experience config @@ -2010,6 +2065,7 @@ permissions: mcmmo.perks.xp.double.mining: true mcmmo.perks.xp.double.repair: true mcmmo.perks.xp.double.smelting: true + mcmmo.perks.xp.double.spears: true mcmmo.perks.xp.double.swords: true mcmmo.perks.xp.double.taming: true mcmmo.perks.xp.double.tridents: true @@ -2048,6 +2104,9 @@ permissions: mcmmo.perks.xp.double.repair: default: false description: Doubles incoming Repair XP + mcmmo.perks.xp.double.spears: + default: false + description: Doubles incoming Smelting XP mcmmo.perks.xp.double.smelting: default: false description: Doubles incoming Smelting XP @@ -2092,6 +2151,7 @@ permissions: mcmmo.perks.xp.quadruple.mining: true mcmmo.perks.xp.quadruple.repair: true mcmmo.perks.xp.quadruple.smelting: true + mcmmo.perks.xp.quadruple.spears: true mcmmo.perks.xp.quadruple.swords: true mcmmo.perks.xp.quadruple.taming: true mcmmo.perks.xp.quadruple.tridents: true @@ -2133,6 +2193,9 @@ permissions: mcmmo.perks.xp.quadruple.smelting: default: false description: Quadruples incoming Smelting XP + mcmmo.perks.xp.quadruple.spears: + default: false + description: Quadruples incoming Spears XP mcmmo.perks.xp.quadruple.swords: default: false description: Quadruples incoming Swords XP @@ -2174,6 +2237,7 @@ permissions: mcmmo.perks.xp.triple.maces: true mcmmo.perks.xp.triple.repair: true mcmmo.perks.xp.triple.smelting: true + mcmmo.perks.xp.triple.spears: true mcmmo.perks.xp.triple.swords: true mcmmo.perks.xp.triple.taming: true mcmmo.perks.xp.triple.tridents: true @@ -2215,6 +2279,9 @@ permissions: mcmmo.perks.xp.triple.smelting: default: false description: Triples incoming Smelting XP + mcmmo.perks.xp.triple.spears: + default: false + description: Triples incoming Spears XP mcmmo.perks.xp.triple.swords: default: false description: Triples incoming Swords XP @@ -2257,6 +2324,7 @@ permissions: mcmmo.skills.salvage: true mcmmo.skills.swords: true mcmmo.skills.smelting: true + mcmmo.skills.spears: true mcmmo.skills.taming: true mcmmo.skills.unarmed: true mcmmo.skills.woodcutting: true @@ -2322,6 +2390,11 @@ permissions: children: mcmmo.ability.smelting.all: true mcmmo.commands.smelting: true + mcmmo.skills.spears: + description: Allows access to the Spears skill + children: + mcmmo.ability.spears.all: true + mcmmo.commands.spears: true mcmmo.skills.swords: description: Allows access to the Swords skill children: diff --git a/src/main/resources/repair.vanilla.yml b/src/main/resources/repair.vanilla.yml index 67d95fb6d..837f4deb8 100644 --- a/src/main/resources/repair.vanilla.yml +++ b/src/main/resources/repair.vanilla.yml @@ -55,6 +55,9 @@ Repairables: WOODEN_SWORD: MinimumLevel: 0 XpMultiplier: .25 + WOODEN_SPEAR: + MinimumLevel: 0 + XpMultiplier: .25 WOODEN_SHOVEL: MinimumLevel: 0 XpMultiplier: .16 @@ -74,6 +77,9 @@ Repairables: STONE_SWORD: MinimumLevel: 0 XpMultiplier: .25 + STONE_SPEAR: + MinimumLevel: 0 + XpMultiplier: .25 STONE_SHOVEL: MinimumLevel: 0 XpMultiplier: .16 @@ -96,6 +102,12 @@ Repairables: ItemType: TOOL ItemMaterialCategory: COPPER RepairMaterial: COPPER_INGOT + COPPER_SPEAR: + MinimumLevel: 0 + XpMultiplier: .3 + ItemType: TOOL + ItemMaterialCategory: COPPER + RepairMaterial: COPPER_INGOT COPPER_SHOVEL: MinimumLevel: 0 XpMultiplier: .2 @@ -152,6 +164,9 @@ Repairables: IRON_SWORD: MinimumLevel: 0 XpMultiplier: .5 + IRON_SPEAR: + MinimumLevel: 0 + XpMultiplier: .5 IRON_SHOVEL: MinimumLevel: 0 XpMultiplier: .3 @@ -190,6 +205,9 @@ Repairables: GOLDEN_SWORD: MinimumLevel: 0 XpMultiplier: 4 + GOLDEN_SPEAR: + MinimumLevel: 0 + XpMultiplier: 4 GOLDEN_SHOVEL: MinimumLevel: 0 XpMultiplier: 2.6 @@ -222,6 +240,9 @@ Repairables: DIAMOND_SWORD: MinimumLevel: 0 XpMultiplier: .5 + DIAMOND_SPEAR: + MinimumLevel: 0 + XpMultiplier: .5 DIAMOND_SHOVEL: MinimumLevel: 0 XpMultiplier: .3 @@ -255,6 +276,9 @@ Repairables: NETHERITE_SWORD: MinimumLevel: 0 XpMultiplier: .6 + NETHERITE_SPEAR: + MinimumLevel: 0 + XpMultiplier: .6 NETHERITE_SHOVEL: MinimumLevel: 0 XpMultiplier: .4 diff --git a/src/main/resources/salvage.vanilla.yml b/src/main/resources/salvage.vanilla.yml index 455c2b6a3..57a442f47 100644 --- a/src/main/resources/salvage.vanilla.yml +++ b/src/main/resources/salvage.vanilla.yml @@ -50,6 +50,10 @@ Salvageables: MinimumLevel: 0 XpMultiplier: .25 MaximumQuantity: 2 + WOODEN_SPEAR: + MinimumLevel: 0 + XpMultiplier: .16 + MaximumQuantity: 1 WOODEN_SHOVEL: MinimumLevel: 0 XpMultiplier: .16 @@ -74,6 +78,10 @@ Salvageables: MinimumLevel: 0 XpMultiplier: .25 MaximumQuantity: 2 + STONE_SPEAR: + MinimumLevel: 0 + XpMultiplier: .16 + MaximumQuantity: 1 STONE_SHOVEL: MinimumLevel: 0 XpMultiplier: .16 @@ -98,6 +106,10 @@ Salvageables: MinimumLevel: 0 XpMultiplier: .4 MaximumQuantity: 2 + COPPER_SPEAR: + MinimumLevel: 0 + XpMultiplier: .25 + MaximumQuantity: 1 COPPER_SHOVEL: MinimumLevel: 0 XpMultiplier: .25 @@ -139,6 +151,10 @@ Salvageables: MinimumLevel: 0 XpMultiplier: .5 MaximumQuantity: 2 + IRON_SPEAR: + MinimumLevel: 0 + XpMultiplier: .3 + MaximumQuantity: 1 IRON_SHOVEL: MinimumLevel: 0 XpMultiplier: .3 @@ -186,6 +202,10 @@ Salvageables: MinimumLevel: 0 XpMultiplier: 4 MaximumQuantity: 2 + GOLDEN_SPEAR: + MinimumLevel: 0 + XpMultiplier: 4 + MaximumQuantity: 1 GOLDEN_SHOVEL: MinimumLevel: 0 XpMultiplier: 2.6 @@ -227,6 +247,10 @@ Salvageables: MinimumLevel: 50 XpMultiplier: .5 MaximumQuantity: 2 + DIAMOND_SPEAR: + MinimumLevel: 50 + XpMultiplier: .5 + MaximumQuantity: 1 DIAMOND_SHOVEL: MinimumLevel: 50 XpMultiplier: .3 @@ -268,6 +292,10 @@ Salvageables: MinimumLevel: 100 XpMultiplier: .5 MaximumQuantity: 4 + NETHERITE_SPEAR: + MinimumLevel: 100 + XpMultiplier: .3 + MaximumQuantity: 4 NETHERITE_SHOVEL: MinimumLevel: 100 XpMultiplier: .3 diff --git a/src/main/resources/skillranks.yml b/src/main/resources/skillranks.yml index 66e2cb06e..6db21161e 100644 --- a/src/main/resources/skillranks.yml +++ b/src/main/resources/skillranks.yml @@ -404,6 +404,72 @@ Smelting: Rank_6: 750 Rank_7: 850 Rank_8: 1000 +Spears: + SpearsLimitBreak: + Standard: + Rank_1: 10 + Rank_2: 20 + Rank_3: 30 + Rank_4: 40 + Rank_5: 50 + Rank_6: 60 + Rank_7: 70 + Rank_8: 80 + Rank_9: 90 + Rank_10: 100 + RetroMode: + Rank_1: 100 + Rank_2: 200 + Rank_3: 300 + Rank_4: 400 + Rank_5: 500 + Rank_6: 600 + Rank_7: 700 + Rank_8: 800 + Rank_9: 900 + Rank_10: 1000 + SpearMastery: + Standard: + Rank_1: 5 + Rank_2: 15 + Rank_3: 30 + Rank_4: 45 + Rank_5: 60 + Rank_6: 75 + Rank_7: 90 + Rank_8: 100 + RetroMode: + Rank_1: 50 + Rank_2: 150 + Rank_3: 300 + Rank_4: 450 + Rank_5: 600 + Rank_6: 750 + Rank_7: 900 + Rank_8: 1000 + Momentum: + Standard: + Rank_1: 1 + Rank_2: 10 + Rank_3: 15 + Rank_4: 20 + Rank_5: 25 + Rank_6: 40 + Rank_7: 50 + Rank_8: 60 + Rank_9: 80 + Rank_10: 95 + RetroMode: + Rank_1: 1 + Rank_2: 100 + Rank_3: 150 + Rank_4: 200 + Rank_5: 250 + Rank_6: 400 + Rank_7: 500 + Rank_8: 600 + Rank_9: 800 + Rank_10: 950 Salvage: ScrapCollector: Standard: diff --git a/src/test/java/com/gmail/nossr50/database/FlatFileDatabaseManagerTest.java b/src/test/java/com/gmail/nossr50/database/FlatFileDatabaseManagerTest.java index 6b8d79099..dd030f71d 100644 --- a/src/test/java/com/gmail/nossr50/database/FlatFileDatabaseManagerTest.java +++ b/src/test/java/com/gmail/nossr50/database/FlatFileDatabaseManagerTest.java @@ -1,21 +1,31 @@ package com.gmail.nossr50.database; +import static com.gmail.nossr50.util.skills.SkillTools.isChildSkill; +import static java.util.UUID.randomUUID; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.gmail.nossr50.api.exceptions.InvalidSkillException; import com.gmail.nossr50.database.flatfile.LeaderboardStatus; import com.gmail.nossr50.datatypes.database.DatabaseType; +import com.gmail.nossr50.datatypes.database.PlayerStat; import com.gmail.nossr50.datatypes.player.PlayerProfile; import com.gmail.nossr50.datatypes.player.UniqueDataType; import com.gmail.nossr50.datatypes.skills.PrimarySkillType; import com.gmail.nossr50.datatypes.skills.SuperAbilityType; -import com.gmail.nossr50.util.skills.SkillTools; +import com.gmail.nossr50.mcMMO; import com.google.common.io.Files; import java.io.BufferedReader; import java.io.File; @@ -32,6 +42,7 @@ import java.util.UUID; import java.util.logging.Filter; import java.util.logging.LogRecord; import java.util.logging.Logger; +import org.bukkit.Server; import org.bukkit.entity.Player; import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.AfterEach; @@ -48,29 +59,35 @@ class FlatFileDatabaseManagerTest { public static final @NotNull String DB_BADDATA = "baddatadb.users"; public static final @NotNull String DB_HEALTHY = "healthydb.users"; public static final @NotNull String HEALTHY_DB_LINE_ONE_UUID_STR = "588fe472-1c82-4c4e-9aa1-7eefccb277e3"; - public static final String DB_MISSING_LAST_LOGIN = "missinglastlogin.users"; - private static File tempDir; - private final static @NotNull Logger logger = Logger.getLogger(Logger.GLOBAL_LOGGER_NAME); - private final long PURGE_TIME = 2630000000L; + public static final @NotNull String DB_MISSING_LAST_LOGIN = "missinglastlogin.users"; - //Making them all unique makes it easier on us to edit this stuff later + private static File tempDir; + private static final @NotNull Logger logger = Logger.getLogger(Logger.GLOBAL_LOGGER_NAME); + + private final long PURGE_TIME = 2_630_000_000L; // ~30 days in ms + + // Making them all unique makes it easier on us to edit this stuff later int expectedLvlMining = 1, expectedLvlWoodcutting = 2, expectedLvlRepair = 3, expectedLvlUnarmed = 4, expectedLvlHerbalism = 5, expectedLvlExcavation = 6, - expectedLvlArchery = 7, expectedLvlSwords = 8, expectedLvlAxes = 9, expectedLvlAcrobatics = 10, - expectedLvlTaming = 11, expectedLvlFishing = 12, expectedLvlAlchemy = 13, expectedLvlCrossbows = 14, - expectedLvlTridents = 15, expectedLvlMaces = 16; + expectedLvlArchery = 7, expectedLvlSwords = 8, expectedLvlAxes = 9, + expectedLvlAcrobatics = 10, expectedLvlTaming = 11, expectedLvlFishing = 12, + expectedLvlAlchemy = 13, expectedLvlCrossbows = 14, expectedLvlTridents = 15, + expectedLvlMaces = 16, expectedLvlSpears = 17; float expectedExpMining = 10, expectedExpWoodcutting = 20, expectedExpRepair = 30, expectedExpUnarmed = 40, expectedExpHerbalism = 50, expectedExpExcavation = 60, - expectedExpArchery = 70, expectedExpSwords = 80, expectedExpAxes = 90, expectedExpAcrobatics = 100, - expectedExpTaming = 110, expectedExpFishing = 120, expectedExpAlchemy = 130, expectedExpCrossbows = 140, - expectedExpTridents = 150, expectedExpMaces = 160; + expectedExpArchery = 70, expectedExpSwords = 80, expectedExpAxes = 90, + expectedExpAcrobatics = 100, expectedExpTaming = 110, expectedExpFishing = 120, + expectedExpAlchemy = 130, expectedExpCrossbows = 140, expectedExpTridents = 150, + expectedExpMaces = 160, expectedExpSpears = 170; long expectedBerserkCd = 111, expectedGigaDrillBreakerCd = 222, expectedTreeFellerCd = 333, - expectedGreenTerraCd = 444, expectedSerratedStrikesCd = 555, expectedSkullSplitterCd = 666, - expectedSuperBreakerCd = 777, expectedBlastMiningCd = 888, expectedChimaeraWingCd = 999, - expectedSuperShotgunCd = 1111, expectedTridentSuperCd = 2222, expectedExplosiveShotCd = 3333, - expectedMacesSuperCd = 4444; + expectedGreenTerraCd = 444, expectedSerratedStrikesCd = 555, + expectedSkullSplitterCd = 666, expectedSuperBreakerCd = 777, + expectedBlastMiningCd = 888, expectedChimaeraWingCd = 999, + expectedSuperShotgunCd = 1111, expectedTridentSuperCd = 2222, + expectedExplosiveShotCd = 3333, expectedMacesSuperCd = 4444, + expectedSpearsSuperCd = 5555; int expectedScoreboardTips = 1111; Long expectedLastLogin = 2020L; @@ -78,10 +95,19 @@ class FlatFileDatabaseManagerTest { @BeforeAll static void initBeforeAll() { logger.setFilter(new DebugFilter()); + // GIVEN a fully mocked mcMMO environment + mcMMO.p = Mockito.mock(mcMMO.class); + when(mcMMO.p.getLogger()).thenReturn(logger); + + // Null player lookup, shouldn't affect tests + Server server = mock(Server.class); + when(mcMMO.p.getServer()).thenReturn(server); + when(server.getPlayerExact(anyString())) + .thenReturn(null); } @BeforeEach - void init() { + void initEachTest() { //noinspection UnstableApiUsage tempDir = Files.createTempDir(); } @@ -95,17 +121,17 @@ class FlatFileDatabaseManagerTest { recursiveDelete(tempDir); } - //Nothing wrong with this database + // Nothing wrong with this database private static final String[] normalDatabaseData = { - "nossr50:1000:::0:1000:640:1000:1000:1000:1000:1000:1000:1000:1000:16:0:500:0:0:0:0:0::1000:0:0:0:1593543012:0:0:0:0::1000:0:0:1593806053:HEARTS:1000:0:588fe472-1c82-4c4e-9aa1-7eefccb277e3:0:0:", - "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:", - "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:0:HEARTS:0:0:e0d07db8-f7e8-43c7-9ded-864dfc6f3b7c:5:1600906906:" + "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:" }; private static final String[] badUUIDDatabaseData = { "nossr50:1000:::0:1000:640:1000:1000:1000:1000:1000:1000:1000:1000:16:0:500:0:0:0:0:0::1000:0:0:0:1593543012:0:0:0:0::1000:0:0:1593806053:HEARTS:1000:0:588fe472-1c82-4c4e-9aa1-7eefccb277e3:0:0:", "z750: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:3:5:1600906906:", - //This one has an incorrect UUID representation + // This one has an incorrect UUID representation "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:0:HEARTS:0:0:e0d07db8-f7e8-43c7-9ded-864dfc6f3b7c:5:1600906906:" }; @@ -113,14 +139,14 @@ class FlatFileDatabaseManagerTest { "nossr50:1000:::0:1000:640:1000:1000:1000:1000:1000:1000:1000:1000:16:0:500:0:0:0:0:0::1000:0:0:0:1593543012:0:0:0:0::1000:0:0:1593806053:HEARTS:1000:0:588fe472-1c82-4c4e-9aa1-7eefccb277e3:0:0:", "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:", "electronicboy: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:0:HEARTS:0:0:e0d07db8-f7e8-43c7-9ded-864dfc6f3b7c:" - //This user is missing data added after UUID index + // This user is missing data added after UUID index }; private static final String[] emptyLineDatabaseData = { "nossr50:1000:::0:1000:640:1000:1000:1000:1000:1000:1000:1000:1000:16:0:500:0:0:0:0:0::1000:0:0:0:1593543012:0:0:0:0::1000:0:0:1593806053:HEARTS:1000:0:588fe472-1c82-4c4e-9aa1-7eefccb277e3:0:0:", "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:", "kashike: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:0:HEARTS:0:0:e0d07db8-f7e8-43c7-9ded-864dfc6f3b7c:5:1600906906:", - "" //EMPTY LINE + "" // EMPTY LINE }; private static final String[] emptyNameDatabaseData = { @@ -148,175 +174,476 @@ class FlatFileDatabaseManagerTest { }; private static final String[] badDatabaseData = { - //First entry here is missing some values + // First entry here is missing some values "nossr50:1000:0:500:0:0:0:0:0::1000:0:0:0:1593543012:0:0:0:0::1000:0:0:1593806053:HEARTS:1000:0:588fe472-1c82-4c4e-9aa1-7eefccb277e3:0:0:", - //Second entry here has an integer value replaced by a string + // Second entry here has an integer value replaced by a string "mrfloris:2420:::0:2452:0:1983:1937:1790:3042:badvalue: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:" }; + // ------------------------------------------------------------------------ + // Core initialization / smoke tests + // ------------------------------------------------------------------------ + @Test - void testDefaultInit() { - new FlatFileDatabaseManager(getTemporaryUserFilePath(), logger, PURGE_TIME, 0); + void defaultInitCreatesDatabaseManagerAndUserFile() { + // Given + When + var databaseManager = new FlatFileDatabaseManager(getTemporaryUserFilePath(), logger, PURGE_TIME, 0); + + // Then + assertNotNull(databaseManager); + assertTrue(databaseManager.getUsersFile().exists()); } @Test - void testUpdateLeaderboards() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void updateLeaderboardsOnEmptyFileReturnsUpdated() { + // Given + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - assertNotNull(flatFileDatabaseManager); - assertEquals(LeaderboardStatus.UPDATED, flatFileDatabaseManager.updateLeaderboards()); + + // When + var status = databaseManager.updateLeaderboards(); + + // Then + assertEquals(LeaderboardStatus.UPDATED, status); } @Test - void testSaveUser() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void updateLeaderboardsCalledTwiceSecondCallReturnsTooSoon() { + // Given + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - //Make a Profile to save and check to see if it worked - UUID uuid = UUID.fromString("588fe472-1c82-4c4e-9aa1-7eefccb277e3"); - String playerName = "nossr50"; - PlayerProfile testProfile = new PlayerProfile(playerName, uuid, 0); - //The above profile should be "zero" initialized - //Save the zero version and see if it looks correct - assertNotNull(flatFileDatabaseManager); - assertTrue(flatFileDatabaseManager.getUsersFile() - .exists()); //Users file should have been created from the above com.gmail.nossr50.database.FlatFileDatabaseManager.checkFileHealthAndStructure - assertNotNull(flatFileDatabaseManager.getUsersFile()); + // When + var firstStatus = databaseManager.updateLeaderboards(); + var secondStatus = databaseManager.updateLeaderboards(); - //The flatFileDatabaseManager is empty at this point, add our user - assertTrue(flatFileDatabaseManager.saveUser(testProfile)); //True means we saved the user + // Then + assertEquals(LeaderboardStatus.UPDATED, firstStatus); + assertEquals(LeaderboardStatus.TOO_SOON_TO_UPDATE, secondStatus); + } - //Check for the empty profile - PlayerProfile retrievedFromData = flatFileDatabaseManager.loadPlayerProfile(uuid); - assertTrue( - retrievedFromData.isLoaded()); //PlayerProfile::isLoaded returns true if the data was created from the file, false if it wasn't found and a dummy profile was returned - assertEquals(uuid, retrievedFromData.getUniqueId()); - assertEquals(playerName, retrievedFromData.getPlayerName()); + // ------------------------------------------------------------------------ + // Save / load user tests + // ------------------------------------------------------------------------ - /* - * Test overwriting names with new names - */ + @Test + void saveUserPersistsUserAndOverwritesNameOnSecondSave() { + // Given + var databaseManager = new FlatFileDatabaseManager( + new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); + UUID uuid = UUID.fromString(HEALTHY_DB_LINE_ONE_UUID_STR); + String originalName = "nossr50"; + var originalProfile = new PlayerProfile(originalName, uuid, 0); - String alteredName = "changedmyname"; - PlayerProfile changedNameProfile = new PlayerProfile(alteredName, uuid, 0); - assertTrue(flatFileDatabaseManager.saveUser( - changedNameProfile)); //True means we saved the user + // When – initial save + assertTrue(databaseManager.getUsersFile().exists()); + assertTrue(databaseManager.saveUser(originalProfile)); - retrievedFromData = flatFileDatabaseManager.loadPlayerProfile(uuid); - assertTrue( - retrievedFromData.isLoaded()); //PlayerProfile::isLoaded returns true if the data was created from the file, false if it wasn't found and a dummy profile was returned - assertEquals(uuid, retrievedFromData.getUniqueId()); - assertEquals(alteredName, retrievedFromData.getPlayerName()); + // Then – initial load + var loadedProfile = databaseManager.loadPlayerProfile(uuid); + assertTrue(loadedProfile.isLoaded()); + assertEquals(uuid, loadedProfile.getUniqueId()); + assertEquals(originalName, loadedProfile.getPlayerName()); + + // Given – updated name + String updatedName = "changedmyname"; + var updatedProfile = new PlayerProfile(updatedName, uuid, 0); + + // When – overwrite + assertTrue(databaseManager.saveUser(updatedProfile)); + + // Then – load again should reflect updated name + var reloadedProfile = databaseManager.loadPlayerProfile(uuid); + assertTrue(reloadedProfile.isLoaded()); + assertEquals(uuid, reloadedProfile.getUniqueId()); + assertEquals(updatedName, reloadedProfile.getPlayerName()); } @Test - void testAddedMissingLastLoginValues() { + void addedMissingLastLoginValuesAreSchemaUpgradedAndSetToMinusOne() { + // Given File dbFile = prepareDatabaseTestResource(DB_MISSING_LAST_LOGIN); - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager(dbFile, - logger, PURGE_TIME, 0, true); - List flagsFound = flatFileDatabaseManager.checkFileHealthAndStructure(); + var databaseManager = new FlatFileDatabaseManager(dbFile, logger, PURGE_TIME, 0, true); + + // When + List flagsFound = databaseManager.checkFileHealthAndStructure(); + + // Then assertNotNull(flagsFound); assertTrue(flagsFound.contains(FlatFileDataFlag.LAST_LOGIN_SCHEMA_UPGRADE)); - //Check for the fixed value - PlayerProfile profile = flatFileDatabaseManager.loadPlayerProfile("nossr50"); + // And – profile last login is set to -1 + var profile = databaseManager.loadPlayerProfile("nossr50"); assertEquals(-1, (long) profile.getLastLogin()); } @Test - void testLoadByName() { - File healthyDB = prepareDatabaseTestResource(DB_HEALTHY); - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager(healthyDB, - logger, PURGE_TIME, 0, true); - List flagsFound = flatFileDatabaseManager.checkFileHealthAndStructure(); - assertNull(flagsFound); //No flags should be found + void loadByNameOnHealthyDatabasePopulatesAllExpectedValues() { + // Given + File healthyDbFile = prepareDatabaseTestResource(DB_HEALTHY); + var databaseManager = new FlatFileDatabaseManager(healthyDbFile, logger, PURGE_TIME, 0, true); + + // When + List flagsFound = databaseManager.checkFileHealthAndStructure(); + + // Then + assertNull(flagsFound); // No flags should be found String playerName = "nossr50"; - UUID uuid = UUID.fromString("588fe472-1c82-4c4e-9aa1-7eefccb277e3"); + UUID uuid = UUID.fromString(HEALTHY_DB_LINE_ONE_UUID_STR); - PlayerProfile profile = flatFileDatabaseManager.loadPlayerProfile(playerName); - testHealthyDataProfileValues(playerName, uuid, profile); + // And – loaded profile has all expected values + var profile = databaseManager.loadPlayerProfile(playerName); + assertHealthyDataProfileValues(playerName, uuid, profile); } @Test - void testNewUser() { - //We will test that new user values line up with our expectations + void newUserCreatesZeroInitializedProfileAndPersistsToFile() throws IOException { + // Given UUID uuid = new UUID(0, 1); String playerName = "nossr50"; + int startingLevel = 1337; + var databaseManager = new FlatFileDatabaseManager( + new File(getTemporaryUserFilePath()), logger, PURGE_TIME, startingLevel, true); + databaseManager.checkFileHealthAndStructure(); - int newUserTestStartingLvl = 1337; - var flatFileDatabaseManager = new FlatFileDatabaseManager( - new File(tempDir.getPath() + File.separator + TEST_FILE_NAME), logger, PURGE_TIME, - newUserTestStartingLvl, true); - flatFileDatabaseManager.checkFileHealthAndStructure(); - - PlayerProfile playerProfile = flatFileDatabaseManager.newUser(playerName, uuid); + // When – create and persist new user + var playerProfile = databaseManager.newUser(playerName, uuid); + // Then – in-memory profile assertTrue(playerProfile.isLoaded()); assertEquals(playerName, playerProfile.getPlayerName()); assertEquals(uuid, playerProfile.getUniqueId()); - PlayerProfile retrievedFromDisk = flatFileDatabaseManager.loadPlayerProfile(uuid); - assertTrue(retrievedFromDisk.isLoaded()); - assertEquals(playerName, retrievedFromDisk.getPlayerName()); - assertEquals(uuid, retrievedFromDisk.getUniqueId()); + // And – from disk + var profileFromDisk = databaseManager.loadPlayerProfile(uuid); + assertTrue(profileFromDisk.isLoaded()); + assertEquals(playerName, profileFromDisk.getPlayerName()); + assertEquals(uuid, profileFromDisk.getUniqueId()); - //Checking a new user for being "zero" initialized - checkNewUserValues(playerProfile, newUserTestStartingLvl); - checkNewUserValues(retrievedFromDisk, newUserTestStartingLvl); + // And – values zero-initialized except level + checkNewUserValues(playerProfile, startingLevel); + checkNewUserValues(profileFromDisk, startingLevel); - //TODO: Should we do any dupe checking? Probably not needed as it would be caught on the next load - flatFileDatabaseManager.newUser("disco", new UUID(3, 3)); - flatFileDatabaseManager.newUser("dingus", new UUID(3, 4)); - flatFileDatabaseManager.newUser("duped_dingus", new UUID(3, 4)); + // Given – add a few more new users (including a duplicate UUID) + databaseManager.newUser("disco", new UUID(3, 3)); + databaseManager.newUser("dingus", new UUID(3, 4)); + databaseManager.newUser("duped_dingus", new UUID(3, 4)); - assertEquals(5, getSplitDataFromFile(flatFileDatabaseManager.getUsersFile()).size()); + // Then – there should be 5 lines (1 header + 4 players) in the DB file + final int lineCount = getSplitDataFromFile(databaseManager.getUsersFile()).size(); + assertEquals(5, lineCount); } @Test - void testAddingUsersToEndOfExistingDB() { - //We will test that new user values line up with our expectations + void addingUsersToEndOfExistingDatabaseKeepsExistingDataAndAppendsNewUsers() throws IOException { + // Given UUID uuid = new UUID(0, 80); String playerName = "the_kitty_man"; + File file = prepareDatabaseTestResource(DB_HEALTHY); + int startingLevel = 1337; + var databaseManager = new FlatFileDatabaseManager(file, logger, PURGE_TIME, startingLevel, true); + databaseManager.checkFileHealthAndStructure(); - File file = prepareDatabaseTestResource(DB_HEALTHY); //Existing DB - - int newUserTestStartingLvl = 1337; - var flatFileDatabaseManager = new FlatFileDatabaseManager(file, logger, PURGE_TIME, - newUserTestStartingLvl, true); - flatFileDatabaseManager.checkFileHealthAndStructure(); - - PlayerProfile playerProfile = flatFileDatabaseManager.newUser(playerName, uuid); + // When – create new user against existing DB + var playerProfile = databaseManager.newUser(playerName, uuid); + // Then assertTrue(playerProfile.isLoaded()); assertEquals(playerName, playerProfile.getPlayerName()); assertEquals(uuid, playerProfile.getUniqueId()); - PlayerProfile retrievedFromDisk = flatFileDatabaseManager.loadPlayerProfile(uuid); - assertTrue(retrievedFromDisk.isLoaded()); - assertEquals(playerName, retrievedFromDisk.getPlayerName()); - assertEquals(uuid, retrievedFromDisk.getUniqueId()); + var profileFromDisk = databaseManager.loadPlayerProfile(uuid); + assertTrue(profileFromDisk.isLoaded()); + assertEquals(playerName, profileFromDisk.getPlayerName()); + assertEquals(uuid, profileFromDisk.getUniqueId()); - //Checking a new user for being "zero" initialized - checkNewUserValues(playerProfile, newUserTestStartingLvl); - checkNewUserValues(retrievedFromDisk, newUserTestStartingLvl); + checkNewUserValues(playerProfile, startingLevel); + checkNewUserValues(profileFromDisk, startingLevel); - //TODO: Should we do any dupe checking? Probably not needed as it would be caught on the next load - flatFileDatabaseManager.newUser("bidoof", new UUID(3, 3)); - flatFileDatabaseManager.newUser("derp", new UUID(3, 4)); - flatFileDatabaseManager.newUser("pizza", new UUID(3, 4)); + // Given – add more users (with duplicate UUID) + databaseManager.newUser("bidoof", new UUID(3, 3)); + databaseManager.newUser("derp", new UUID(3, 4)); + databaseManager.newUser("pizza", new UUID(3, 4)); - assertEquals(7, getSplitDataFromFile(flatFileDatabaseManager.getUsersFile()).size()); + final int originalLineCount = getSplitDataFromFile(databaseManager.getUsersFile()).size(); + assertEquals(7, originalLineCount); - //Now we *fix* the flatFileDatabaseManager and there should be one less - flatFileDatabaseManager.checkFileHealthAndStructure(); - assertEquals(6, getSplitDataFromFile(flatFileDatabaseManager.getUsersFile()).size()); + // When – run health checker to fix duplicates + databaseManager.checkFileHealthAndStructure(); + + // Then – one of the duplicates should be removed + final int lineCountAfterFix = getSplitDataFromFile(databaseManager.getUsersFile()).size(); + assertEquals(6, lineCountAfterFix); + } + + @Test + void readLeaderboardForPowerLevelsReturnsCorrectPagedResults() throws InvalidSkillException { + // Given + var databaseManager = createDatabaseWithTwoRankedUsers(); + + // When – page 1 (top player only) + // Gherkin: Given a leaderboard with two users + // When we read page 1 with 1 stat per page + // Then we see the top user "leader" + List firstPage = databaseManager.readLeaderboard(null, 1, 1); + + // When – page 2 (second player only) + List secondPage = databaseManager.readLeaderboard(null, 2, 1); + + // When – page 3 (out of range) + List thirdPage = databaseManager.readLeaderboard(null, 3, 1); + + // When – page 0 (should behave like page 1 due to Math.max) + List pageZero = databaseManager.readLeaderboard(null, 0, 10); + + // Then + assertEquals(1, firstPage.size()); + assertEquals("leader", firstPage.get(0).playerName()); + + assertEquals(1, secondPage.size()); + assertEquals("follower", secondPage.get(0).playerName()); + + assertTrue(thirdPage.isEmpty(), "Out-of-range page should be empty"); + + assertFalse(pageZero.isEmpty()); + assertEquals("leader", pageZero.get(0).playerName()); + } + + @Test + void saveUserUuidUpdatesMatchingUserAndReturnsTrue() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( + new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); + replaceDataInFile(databaseManager, normalDatabaseData); + + String targetUser = "nossr50"; + UUID newUuid = randomUUID(); + + // When + // Gherkin: Given a valid flatfile entry for "nossr50" + // When we update the UUID + // Then the UUID in the file is replaced and the method returns true + boolean worked = databaseManager.saveUserUUID(targetUser, newUuid); + + // Then + assertTrue(worked); + + var lines = getSplitDataFromFile(databaseManager.getUsersFile()); + boolean foundNewUuid = false; + boolean oldUuidStillPresent = false; + + for (String[] split : lines) { + if (split.length > FlatFileDatabaseManager.UUID_INDEX && + targetUser.equalsIgnoreCase(split[FlatFileDatabaseManager.USERNAME_INDEX])) { + if (split[FlatFileDatabaseManager.UUID_INDEX].equals(newUuid.toString())) { + foundNewUuid = true; + } + if (split[FlatFileDatabaseManager.UUID_INDEX].equals(HEALTHY_DB_LINE_ONE_UUID_STR)) { + oldUuidStillPresent = true; + } + } + } + + assertTrue(foundNewUuid, "New UUID must be written for target user"); + assertFalse(oldUuidStillPresent, "Old UUID must not remain for target user"); + } + + @Test + void saveUserUuidWithShortEntryDoesNotModifyDataAndReturnsFalse() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( + new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); + + String shortLine = "shortUser:1:2:3"; // very few fields => character.length < 42 + String header = "# test header"; + replaceDataInFile(databaseManager, new String[]{header, shortLine}); + + UUID newUuid = randomUUID(); + + // When + // Gherkin: Given an invalid short database entry + // When we attempt to update its UUID + // Then the method returns false and the line stays unchanged + boolean worked = databaseManager.saveUserUUID("shortUser", newUuid); + + // Then + assertFalse(worked); + + try (BufferedReader reader = new BufferedReader( + new FileReader(databaseManager.getUsersFile()))) { + assertEquals(header, reader.readLine()); + assertEquals(shortLine, reader.readLine()); + assertNull(reader.readLine()); + } + } + + @Test + void convertUsersCopiesAllNonCommentLinesToDestination() throws IOException { + // Given + var sourceDatabase = new FlatFileDatabaseManager( + new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); + + String lineOne = "# mcMMO header"; + String lineTwo = ""; + String lineThree = normalDatabaseData[0]; + String lineFour = normalDatabaseData[1]; + + replaceDataInFile(sourceDatabase, new String[]{lineOne, lineTwo, lineThree, lineFour}); + + DatabaseManager destination = mock(DatabaseManager.class); + + // When + // Gherkin: Given a flatfile with comments, empty lines, and two users + // When we convert users into another DatabaseManager + // Then saveUser is called once for each user line + sourceDatabase.convertUsers(destination); + + // Then + verify(destination, times(2)).saveUser(any(PlayerProfile.class)); + } + + @Test + void convertUsersContinuesWhenDestinationSaveThrowsException() throws IOException { + // Given + var sourceDatabase = new FlatFileDatabaseManager( + new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); + + String header = "# mcMMO header"; + String userLine1 = normalDatabaseData[0]; + String userLine2 = normalDatabaseData[1]; + + replaceDataInFile(sourceDatabase, new String[]{header, userLine1, userLine2}); + + DatabaseManager destination = mock(DatabaseManager.class); + + // First call throws, second call succeeds + Mockito.doThrow(new RuntimeException("boom")) + .when(destination) + .saveUser(any(PlayerProfile.class)); + + // When + // Gherkin: Given a destination that sometimes throws on save + // When we convert users + // Then conversion does not fail and all users are attempted + sourceDatabase.convertUsers(destination); + + // Then + verify(destination, times(2)) + .saveUser(any(PlayerProfile.class)); + } + + private String lineWithLastLogin(String baseLine, long lastLogin) { + String[] data = baseLine.split(":"); + data[FlatFileDatabaseManager.OVERHAUL_LAST_LOGIN] = Long.toString(lastLogin); + return String.join(":", data) + ":"; // keep trailing colon similarity + } + + @Test + void purgeOldUsersRemovesOnlyEntriesOlderThanPurgeTime() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( + new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); + + long now = System.currentTimeMillis(); + + // Old user: lastLogin = 0 (definitely older than PURGE_TIME) + String veryOldUser = lineWithLastLogin(normalDatabaseData[0], 0L); // username: nossr50 + + // Recent user: lastLogin ~ now (definitely NOT older than PURGE_TIME) + String recentUser = lineWithLastLogin(normalDatabaseData[1], now); // username: mrfloris + + // Short line – not enough fields, should be preserved + String shortLine = "shortUser:1:2"; + + String header = "# purgeOldUsers header"; + + replaceDataInFile(databaseManager, new String[]{header, veryOldUser, recentUser, shortLine}); + + // When + // Gherkin: Given a mix of old users, recent users, comments and short lines + // When we purge old users + // Then only the truly old users are removed + databaseManager.purgeOldUsers(); + + // Then + List remaining = getSplitDataFromFile(databaseManager.getUsersFile()); + List remainingNames = new ArrayList<>(); + + for (String[] split : remaining) { + if (split.length > FlatFileDatabaseManager.USERNAME_INDEX) { + remainingNames.add(split[FlatFileDatabaseManager.USERNAME_INDEX]); + } + } + + assertTrue(remainingNames.contains("mrfloris"), "Recent user must be kept"); + assertTrue(remainingNames.contains("shortUser"), "Short line must be preserved"); + assertFalse(remainingNames.contains("nossr50"), "Very old user must be purged"); + } + + @Test + void removeUserWhenUserExistsRemovesLineAndReturnsTrue() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( + new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); + + String header = "# removeUser header"; + replaceDataInFile(databaseManager, new String[]{header, + normalDatabaseData[0], // nossr50 + normalDatabaseData[1], // mrfloris + normalDatabaseData[2] // powerless + }); + + // When + // Gherkin: Given a database containing a user named powerless + // When we remove that user + // Then the user entry disappears from the file and the method returns true + boolean worked = databaseManager.removeUser("powerless", randomUUID()); + + // Then + assertTrue(worked); + + List remaining = getSplitDataFromFile(databaseManager.getUsersFile()); + List remainingNames = new ArrayList<>(); + for (String[] split : remaining) { + remainingNames.add(split[FlatFileDatabaseManager.USERNAME_INDEX]); + } + + assertTrue(remainingNames.contains("nossr50")); + assertTrue(remainingNames.contains("mrfloris")); + assertFalse(remainingNames.contains("powerless")); + } + + @Test + void removeUserWhenUserDoesNotExistReturnsFalseAndLeavesFileUnchanged() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( + new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); + + replaceDataInFile(databaseManager, normalDatabaseData); + List before = getSplitDataFromFile(databaseManager.getUsersFile()); + + // When + // Gherkin: Given a database that does not contain user ghostUser + // When we attempt to remove ghostUser + // Then the method returns false and the file contents are unchanged + boolean worked = databaseManager.removeUser("ghostUser", randomUUID()); + + // Then + assertFalse(worked); + + List after = getSplitDataFromFile(databaseManager.getUsersFile()); + assertEquals(before.size(), after.size()); + + for (int i = 0; i < before.size(); i++) { + assertArrayEquals(before.get(i), after.get(i)); + } } private void checkNewUserValues(@NotNull PlayerProfile playerProfile, int startingLevel) { - //Checking a new user for being zero initialized + // Given / Then – new user should be zero-initialized for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { - if (SkillTools.isChildSkill(primarySkillType)) { + if (isChildSkill(primarySkillType)) { continue; } @@ -329,71 +656,80 @@ class FlatFileDatabaseManagerTest { } assertTrue(playerProfile.getLastLogin() > 0); - assertEquals(playerProfile.getChimaerWingDATS(), 0); - assertEquals(playerProfile.getScoreboardTipsShown(), 0); + assertEquals(0, playerProfile.getChimaerWingDATS()); + assertEquals(0, playerProfile.getScoreboardTipsShown()); } @Test - void testLoadByUUID() { + void loadByUUIDOnHealthyDatabaseReturnsExpectedProfile() { + // Given File dbFile = prepareDatabaseTestResource(DB_HEALTHY); - var flatFileDatabaseManager = new FlatFileDatabaseManager(dbFile, logger, PURGE_TIME, 0, - true); - List flagsFound = flatFileDatabaseManager.checkFileHealthAndStructure(); - assertNull(flagsFound); //No flags should be found + var databaseManager = new FlatFileDatabaseManager(dbFile, logger, PURGE_TIME, 0, true); - /* - * Once the flatFileDatabaseManager looks fine load the profile - */ + // When + var flagsFound = databaseManager.checkFileHealthAndStructure(); + + // Then + assertNull(flagsFound); // No flags should be found String playerName = "nossr50"; - UUID uuid = UUID.fromString("588fe472-1c82-4c4e-9aa1-7eefccb277e3"); + UUID uuid = UUID.fromString(HEALTHY_DB_LINE_ONE_UUID_STR); - PlayerProfile profile1 = flatFileDatabaseManager.loadPlayerProfile(uuid); - testHealthyDataProfileValues(playerName, uuid, profile1); + var loadedProfile = databaseManager.loadPlayerProfile(uuid); + assertHealthyDataProfileValues(playerName, uuid, loadedProfile); - assertFalse(flatFileDatabaseManager.loadPlayerProfile(new UUID(0, 1)) - .isLoaded()); //This profile should not exist and therefor will return unloaded + // And – unknown UUID should return an unloaded profile + assertFalse(databaseManager.loadPlayerProfile(new UUID(0, 1)).isLoaded()); } @Test - void testLoadByUUIDAndName() { + void loadByUUIDAndNameRenamesProfileWhenNameHasChanged() { + // Given File dbFile = prepareDatabaseTestResource(DB_HEALTHY); - var flatFileDatabaseManager = new FlatFileDatabaseManager(dbFile, logger, PURGE_TIME, 0, - true); - List flagsFound = flatFileDatabaseManager.checkFileHealthAndStructure(); - assertNull(flagsFound); //No flags should be found + var databaseManager = new FlatFileDatabaseManager(dbFile, logger, PURGE_TIME, 0, true); + List flagsFound = databaseManager.checkFileHealthAndStructure(); + assertNull(flagsFound); - String playerName = "nossr50"; - UUID uuid = UUID.fromString("588fe472-1c82-4c4e-9aa1-7eefccb277e3"); + String originalName = "nossr50"; + UUID uuid = UUID.fromString(HEALTHY_DB_LINE_ONE_UUID_STR); + Player originalPlayer = initMockPlayer(originalName, uuid); - Player player = initMockPlayer(playerName, uuid); - PlayerProfile profile1 = flatFileDatabaseManager.loadPlayerProfile(player); - testHealthyDataProfileValues(playerName, uuid, profile1); + // When – load with original name + var originalProfile = databaseManager.loadPlayerProfile(originalPlayer); + // Then + assertHealthyDataProfileValues(originalName, uuid, originalProfile); + + // Given – same UUID but new name String updatedName = "updatedName"; - Player updatedNamePlayer = initMockPlayer(updatedName, uuid); - PlayerProfile updatedNameProfile = flatFileDatabaseManager.loadPlayerProfile( - updatedNamePlayer); - testHealthyDataProfileValues(updatedName, uuid, updatedNameProfile); + Player updatedPlayer = initMockPlayer(updatedName, uuid); - Player shouldNotExist = initMockPlayer("doesntexist", new UUID(0, 1)); - PlayerProfile profile3 = flatFileDatabaseManager.loadPlayerProfile(shouldNotExist); - assertFalse(profile3.isLoaded()); + // When – load again + var updatedProfile = databaseManager.loadPlayerProfile(updatedPlayer); + + // Then – database name should be updated to new value + assertHealthyDataProfileValues(updatedName, uuid, updatedProfile); + + // And – unknown player returns unloaded profile + Player missingPlayer = initMockPlayer("doesntexist", new UUID(0, 1)); + var missingProfile = databaseManager.loadPlayerProfile(missingPlayer); + assertFalse(missingProfile.isLoaded()); } private File prepareDatabaseTestResource(@NotNull String dbFileName) { - ClassLoader classLoader = getClass().getClassLoader(); - URI resourceFileURI = null; + // Given + var classLoader = getClass().getClassLoader(); + URI resourceFileURI; try { resourceFileURI = classLoader.getResource(dbFileName).toURI(); } catch (URISyntaxException e) { - e.printStackTrace(); + throw new RuntimeException(e); } + // Then – resource exists assertNotNull(resourceFileURI); File fromResourcesFile = new File(resourceFileURI); - assertNotNull(resourceFileURI); File copyOfFile = new File(tempDir.getPath() + File.separator + dbFileName); if (copyOfFile.exists()) { @@ -407,41 +743,37 @@ class FlatFileDatabaseManagerTest { //noinspection UnstableApiUsage Files.copy(fromResourcesFile, copyOfFile); } catch (IOException e) { - e.printStackTrace(); + throw new RuntimeException(e); } assertNotNull(copyOfFile); return copyOfFile; } - private void testHealthyDataProfileValues(@NotNull String playerName, @NotNull UUID uuid, + private void assertHealthyDataProfileValues(@NotNull String expectedPlayerName, + @NotNull UUID expectedUuid, @NotNull PlayerProfile profile) { - assertTrue( - profile.isLoaded()); //PlayerProfile::isLoaded returns true if the data was created from the file, false if it wasn't found and a dummy profile was returned - assertEquals(uuid, profile.getUniqueId()); - assertEquals(playerName, profile.getPlayerName()); - - /* - * Player is a match and data is loaded, check values - */ + // Given / Then – profile is loaded and matches basic identity + assertTrue(profile.isLoaded()); + assertEquals(expectedUuid, profile.getUniqueId()); + assertEquals(expectedPlayerName, profile.getPlayerName()); + // And – skill levels & XP match expected values for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { - if (SkillTools.isChildSkill(primarySkillType)) { + if (isChildSkill(primarySkillType)) { continue; } - int expectedLevelHealthyDBEntryOne = getExpectedLevelHealthyDBEntryOne( - primarySkillType); - int skillLevel = profile.getSkillLevel(primarySkillType); - assertEquals(expectedLevelHealthyDBEntryOne, skillLevel); + int expectedLevel = getExpectedLevelHealthyDBEntryOne(primarySkillType); + int actualLevel = profile.getSkillLevel(primarySkillType); + assertEquals(expectedLevel, actualLevel); - float expectedExperienceHealthyDBEntryOne = getExpectedExperienceHealthyDBEntryOne( - primarySkillType); - float skillXpLevelRaw = profile.getSkillXpLevelRaw(primarySkillType); - assertEquals(expectedExperienceHealthyDBEntryOne, skillXpLevelRaw, 0); + float expectedExperience = getExpectedExperienceHealthyDBEntryOne(primarySkillType); + float actualExperience = profile.getSkillXpLevelRaw(primarySkillType); + assertEquals(expectedExperience, actualExperience, 0); } - //Check the other things + // And – super ability cooldowns match expected values for (SuperAbilityType superAbilityType : SuperAbilityType.values()) { assertEquals(getExpectedSuperAbilityDATS(superAbilityType), profile.getAbilityDATS(superAbilityType)); @@ -467,15 +799,13 @@ class FlatFileDatabaseManagerTest { case TRIDENTS_SUPER_ABILITY -> expectedTridentSuperCd; case EXPLOSIVE_SHOT -> expectedExplosiveShotCd; case MACES_SUPER_ABILITY -> expectedMacesSuperCd; - default -> - throw new RuntimeException("Values not defined for super ability please add " + - "values for " + superAbilityType + " to the test"); + case SPEARS_SUPER_ABILITY -> expectedSpearsSuperCd; + default -> throw new RuntimeException( + "Values not defined for super ability, please add " + superAbilityType); }; - } - private float getExpectedExperienceHealthyDBEntryOne( - @NotNull PrimarySkillType primarySkillType) { + private float getExpectedExperienceHealthyDBEntryOne(@NotNull PrimarySkillType primarySkillType) { return switch (primarySkillType) { case ACROBATICS -> expectedExpAcrobatics; case ALCHEMY -> expectedExpAlchemy; @@ -494,11 +824,10 @@ class FlatFileDatabaseManagerTest { case UNARMED -> expectedExpUnarmed; case WOODCUTTING -> expectedExpWoodcutting; case MACES -> expectedExpMaces; + case SPEARS -> expectedExpSpears; default -> throw new RuntimeException( - "Values for skill not defined, please add values for " - + primarySkillType + " to the test"); + "Values for skill not defined, please add values for " + primarySkillType); }; - } private int getExpectedLevelHealthyDBEntryOne(@NotNull PrimarySkillType primarySkillType) { @@ -520,146 +849,189 @@ class FlatFileDatabaseManagerTest { case UNARMED -> expectedLvlUnarmed; case WOODCUTTING -> expectedLvlWoodcutting; case MACES -> expectedLvlMaces; + case SPEARS -> expectedLvlSpears; default -> throw new RuntimeException( - "Values for skill not defined, please add values for " - + primarySkillType + " to the test"); + "Values for skill not defined, please add values for " + primarySkillType); }; - } + // ------------------------------------------------------------------------ + // File health & structure tests + // ------------------------------------------------------------------------ + @Test - void testOverwriteName() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void overwriteName_whenDuplicateNamesExist_rewritesSecondName() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - overwriteDataAndCheckForFlag(flatFileDatabaseManager, duplicateNameDatabaseData, + + // When – overwrite with duplicate name database and fix + overwriteDataAndCheckForFlag(databaseManager, duplicateNameDatabaseData, FlatFileDataFlag.DUPLICATE_NAME); - ArrayList splitDataLines = getSplitDataFromFile( - flatFileDatabaseManager.getUsersFile()); - assertNotEquals(splitDataLines.get(1)[0], splitDataLines.get(0)[0]); //Name comparison + + // Then – names should no longer be equal + var splitDataLines = getSplitDataFromFile(databaseManager.getUsersFile()); + assertNotEquals(splitDataLines.get(1)[0], splitDataLines.get(0)[0]); } @Test - void testDataNotFound() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void loadPlayerProfileOnMissingData_returnsUnloadedProfile() { + // Given + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - //Save the zero version and see if it looks correct - assertNotNull(flatFileDatabaseManager); - assertTrue(flatFileDatabaseManager.getUsersFile().exists()); - assertNotNull(flatFileDatabaseManager.getUsersFile()); - //Check for the "unloaded" profile - PlayerProfile retrievedFromData = flatFileDatabaseManager.loadPlayerProfile("nossr50"); - assertFalse( - retrievedFromData.isLoaded()); //PlayerProfile::isLoaded returns false if data doesn't exist for the user + // When + var retrievedProfile = databaseManager.loadPlayerProfile("nossr50"); + + // Then + assertFalse(retrievedProfile.isLoaded()); } @Test - void testPurgePowerlessUsers() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void purgePowerlessUsersRemovesOnlyUsersWithAllZeroSkills() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - replaceDataInFile(flatFileDatabaseManager, normalDatabaseData); - int purgeCount = flatFileDatabaseManager.purgePowerlessUsers(); - assertEquals(purgeCount, 1); //1 User should have been purged + replaceDataInFile(databaseManager, normalDatabaseData); + + // When + int purgedCount = databaseManager.purgePowerlessUsers(); + + // Then + assertEquals(1, purgedCount); // 1 user should have been purged } @Test - void testCheckFileHealthAndStructure() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void checkFileHealthAndStructureOnBadDatabaseReturnsNonEmptyFlags() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - replaceDataInFile(flatFileDatabaseManager, badDatabaseData); + replaceDataInFile(databaseManager, badDatabaseData); - List dataFlags = flatFileDatabaseManager.checkFileHealthAndStructure(); + // When + var dataFlags = databaseManager.checkFileHealthAndStructure(); + + // Then assertNotNull(dataFlags); - assertNotEquals(dataFlags.size(), 0); + assertNotEquals(0, dataFlags.size()); } @Test - void testFindFixableDuplicateNames() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void findFixableDuplicateNamesDetectsDuplicateNameFlag() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - overwriteDataAndCheckForFlag(flatFileDatabaseManager, duplicateNameDatabaseData, + + // When / Then + overwriteDataAndCheckForFlag(databaseManager, duplicateNameDatabaseData, FlatFileDataFlag.DUPLICATE_NAME); } @Test - void testFindDuplicateUUIDs() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void findDuplicateUUIDsDetectsDuplicateUuidFlag() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - overwriteDataAndCheckForFlag(flatFileDatabaseManager, duplicateUUIDDatabaseData, + + // When / Then + overwriteDataAndCheckForFlag(databaseManager, duplicateUUIDDatabaseData, FlatFileDataFlag.DUPLICATE_UUID); } - @Test() - void findBadUUIDData() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + @Test + void findBadUUIDDataSetsBadUuidDataFlag() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - overwriteDataAndCheckForFlag(flatFileDatabaseManager, badUUIDDatabaseData, + + // When / Then + overwriteDataAndCheckForFlag(databaseManager, badUUIDDatabaseData, FlatFileDataFlag.BAD_UUID_DATA); } @Test - void testFindCorruptData() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void findCorruptDataSetsCorruptedOrUnrecognizableFlag() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - overwriteDataAndCheckForFlag(flatFileDatabaseManager, corruptDatabaseData, + + // When / Then + overwriteDataAndCheckForFlag(databaseManager, corruptDatabaseData, FlatFileDataFlag.CORRUPTED_OR_UNRECOGNIZABLE); } @Test - void testFindEmptyNames() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void findEmptyNamesSetsMissingNameFlag() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - overwriteDataAndCheckForFlag(flatFileDatabaseManager, emptyNameDatabaseData, + + // When / Then + overwriteDataAndCheckForFlag(databaseManager, emptyNameDatabaseData, FlatFileDataFlag.MISSING_NAME); } @Test - void testFindBadValues() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void findBadValuesSetsBadValuesFlag() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - overwriteDataAndCheckForFlag(flatFileDatabaseManager, badDatabaseData, + + // When / Then + overwriteDataAndCheckForFlag(databaseManager, badDatabaseData, FlatFileDataFlag.BAD_VALUES); } @Test - void testFindOutdatedData() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void findOutdatedDataSetsIncompleteFlag() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - overwriteDataAndCheckForFlag(flatFileDatabaseManager, outdatedDatabaseData, + + // When / Then + overwriteDataAndCheckForFlag(databaseManager, outdatedDatabaseData, FlatFileDataFlag.INCOMPLETE); } @Test - void testGetDatabaseType() { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void getDatabaseTypeReturnsFlatFileType() { + // Given / When + DatabaseManager databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - assertNotNull(flatFileDatabaseManager); - assertEquals(flatFileDatabaseManager.getDatabaseType(), DatabaseType.FLATFILE); + + // Then + assertEquals(DatabaseType.FLATFILE, databaseManager.getDatabaseType()); } + // ------------------------------------------------------------------------ + // Leaderboards & ranks + // ------------------------------------------------------------------------ + @Test - void testReadRank() { - //This is an empty DB - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( + void readRankReturnsRanksForAllSkillsAndPowerLevel() { + // Given – empty DB and two users with different levels + var databaseManager = new FlatFileDatabaseManager( new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - String rankBoyName = "rankBoy"; - UUID rankBoyUUID = new UUID(1337, 1337); - String rankGirlName = "rankGirl"; - UUID rankGirlUUID = new UUID(7331, 7331); + final String rankGirlName = "rankGirl"; + final UUID rankGirlUUID = randomUUID(); + final String rankBoyName = "rankBoy"; + final UUID rankBoyUUID = randomUUID(); - PlayerProfile rankGirlProfile = addPlayerProfileWithLevelsAndSave(rankGirlName, - rankGirlUUID, 100); //Rank 1 - PlayerProfile rankBoyProfile = addPlayerProfileWithLevelsAndSave(rankBoyName, rankBoyUUID, - 10); //Rank 2 + // Rank 1 + addPlayerProfileWithLevelsAndSave(databaseManager, rankGirlName, rankGirlUUID, 100); + // Rank 2 + addPlayerProfileWithLevelsAndSave(databaseManager, rankBoyName, rankBoyUUID, 10); - assertEquals(LeaderboardStatus.UPDATED, flatFileDatabaseManager.updateLeaderboards()); - Map rankGirlPositions = flatFileDatabaseManager.readRank( - rankGirlName); - Map rankBoyPositions = flatFileDatabaseManager.readRank( - rankBoyName); + // When + assertEquals(LeaderboardStatus.UPDATED, databaseManager.updateLeaderboards()); + final Map rankGirlPositions = + databaseManager.readRank(rankGirlName); + final Map rankBoyPositions = + databaseManager.readRank(rankBoyName); + // Then – skill ranks for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { - if (primarySkillType.isChildSkill()) { + if (isChildSkill(primarySkillType)) { assertNull(rankBoyPositions.get(primarySkillType)); assertNull(rankGirlPositions.get(primarySkillType)); } else { @@ -668,26 +1040,42 @@ class FlatFileDatabaseManagerTest { } } - assertEquals(1, flatFileDatabaseManager.readRank(rankGirlName) - .get(null)); //Girl should be position 1 - assertEquals(2, - flatFileDatabaseManager.readRank(rankBoyName).get(null)); //Boy should be position 2 + // And – power level rank (null key) + assertEquals(1, databaseManager.readRank(rankGirlName).get(null)); + assertEquals(2, databaseManager.readRank(rankBoyName).get(null)); } @Test - void testLoadFromFile() { + void readLeaderboardChildSkillThrowsInvalidSkillException() { + // Given + var databaseManager = new FlatFileDatabaseManager( + new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); + + // When / Then + assertThrows(InvalidSkillException.class, () -> + databaseManager.readLeaderboard(PrimarySkillType.SALVAGE, 1, 10)); + } + + @Test + void getStoredUsersReturnsAllUsernamesFromFlatFile() throws IOException { + // Given + var databaseManager = new FlatFileDatabaseManager( + new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); + replaceDataInFile(databaseManager, normalDatabaseData); + + // When + List storedUsers = databaseManager.getStoredUsers(); + + // Then + assertEquals(List.of("nossr50", "mrfloris", "powerless"), storedUsers); + } + + @Test + void loadFromFileWithBadDataFileSetsBadValuesFlag() throws URISyntaxException, IOException { + // Given ClassLoader classLoader = getClass().getClassLoader(); - URI resourceFileURI = null; - - try { - resourceFileURI = classLoader.getResource(DB_BADDATA).toURI(); - } catch (URISyntaxException e) { - e.printStackTrace(); - } - - assertNotNull(resourceFileURI); + URI resourceFileURI = classLoader.getResource(DB_BADDATA).toURI(); File fromResourcesFile = new File(resourceFileURI); - assertNotNull(resourceFileURI); File copyOfFile = new File(tempDir.getPath() + File.separator + DB_BADDATA); if (copyOfFile.exists()) { @@ -695,30 +1083,26 @@ class FlatFileDatabaseManagerTest { } assertTrue(fromResourcesFile.exists()); + Files.copy(fromResourcesFile, copyOfFile); - try { - Files.copy(fromResourcesFile, copyOfFile); - } catch (IOException e) { - e.printStackTrace(); - } - - assertNotNull(copyOfFile); - - //This makes sure our private method is working before the tests run afterwards + // When – read file via helper ArrayList dataFromFile = getSplitDataFromFile(copyOfFile); + + // Then – sanity check the file contents logger.info("File Path: " + copyOfFile.getAbsolutePath()); assertArrayEquals(BAD_FILE_LINE_ONE.split(":"), dataFromFile.get(0)); - assertEquals(dataFromFile.get(22)[0], "nossr51"); + assertEquals("nossr51", dataFromFile.get(22)[0]); assertArrayEquals(BAD_DATA_FILE_LINE_TWENTY_THREE.split(":"), dataFromFile.get(22)); - FlatFileDatabaseManager db_a = new FlatFileDatabaseManager(copyOfFile, logger, PURGE_TIME, - 0, true); - List flagsFound = db_a.checkFileHealthAndStructure(); + // And – health check should contain BAD_VALUES flag + var databaseManager = new FlatFileDatabaseManager(copyOfFile, logger, PURGE_TIME, 0, true); + List flagsFound = databaseManager.checkFileHealthAndStructure(); assertNotNull(flagsFound); assertTrue(flagsFound.contains(FlatFileDataFlag.BAD_VALUES)); } - private @NotNull ArrayList getSplitDataFromFile(@NotNull File file) { + private @NotNull ArrayList getSplitDataFromFile(@NotNull File file) + throws IOException { ArrayList splitDataList = new ArrayList<>(); try (BufferedReader bufferedReader = new BufferedReader(new FileReader(file))) { @@ -733,40 +1117,48 @@ class FlatFileDatabaseManagerTest { splitDataList.add(splitData); } - } catch (Exception e) { - e.printStackTrace(); + } catch (FileNotFoundException e) { + logger.info("File not found"); + throw e; + } catch (IOException e) { + logger.info("IOException reading file"); + throw e; } return splitDataList; } - private @NotNull PlayerProfile addPlayerProfileWithLevelsAndSave(String playerName, UUID uuid, + private @NotNull PlayerProfile addPlayerProfileWithLevelsAndSave( + FlatFileDatabaseManager databaseManager, + String playerName, + UUID uuid, int levels) { - FlatFileDatabaseManager flatFileDatabaseManager = new FlatFileDatabaseManager( - new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); - assertFalse(flatFileDatabaseManager.loadPlayerProfile(uuid).isLoaded()); - flatFileDatabaseManager.newUser(playerName, uuid); - PlayerProfile leveledProfile = flatFileDatabaseManager.loadPlayerProfile(uuid); + // Given – DB should not already contain this profile + assertFalse(databaseManager.loadPlayerProfile(uuid).isLoaded()); + + // When – create new user and level them + databaseManager.newUser(playerName, uuid); + PlayerProfile leveledProfile = databaseManager.loadPlayerProfile(uuid); assertTrue(leveledProfile.isLoaded()); assertEquals(playerName, leveledProfile.getPlayerName()); assertEquals(uuid, leveledProfile.getUniqueId()); for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { - if (SkillTools.isChildSkill(primarySkillType)) { + if (isChildSkill(primarySkillType)) { continue; } - leveledProfile.modifySkill(primarySkillType, - levels); //TODO: This method also resets XP, not cool + // Note: this also resets XP + leveledProfile.modifySkill(primarySkillType, levels); } - flatFileDatabaseManager.saveUser(leveledProfile); - leveledProfile = flatFileDatabaseManager.loadPlayerProfile(uuid); + databaseManager.saveUser(leveledProfile); + leveledProfile = databaseManager.loadPlayerProfile(uuid); for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { - if (SkillTools.isChildSkill(primarySkillType)) { + if (isChildSkill(primarySkillType)) { continue; } @@ -776,65 +1168,41 @@ class FlatFileDatabaseManagerTest { return leveledProfile; } - private void replaceDataInFile(@NotNull FlatFileDatabaseManager flatFileDatabaseManager, - @NotNull String[] dataEntries) { - String filePath = flatFileDatabaseManager.getUsersFile().getAbsolutePath(); - BufferedReader in = null; - FileWriter out = null; + private void replaceDataInFile(@NotNull FlatFileDatabaseManager databaseManager, + @NotNull String[] dataEntries) throws IOException { + String filePath = databaseManager.getUsersFile().getAbsolutePath(); - try { + // Given / When – overwrite file contents with provided entries + try (FileWriter out = new FileWriter(filePath)) { StringBuilder writer = new StringBuilder(); - for (String data : dataEntries) { writer.append(data).append("\r\n"); } - - out = new FileWriter(filePath); out.write(writer.toString()); - } catch (FileNotFoundException e) { - e.printStackTrace(); - logger.info("File not found"); - } catch (IOException e) { - e.printStackTrace(); - } finally { - if (out != null) { - try { - out.close(); - } catch (IOException e) { - // Ignore - } - } } - try { - logger.info( - "Added the following lines to the FlatFileDatabase for the purposes of the test..."); - // Open the file - in = new BufferedReader(new FileReader(filePath)); + // Then – log resulting contents for debug visibility + try (BufferedReader in = new BufferedReader(new FileReader(filePath))) { + logger.info("Added the following lines to the FlatFileDatabase for the purposes of the test..."); String line; while ((line = in.readLine()) != null) { logger.info(line); } - } catch (IOException e) { - e.printStackTrace(); - } finally { - if (in != null) { - try { - in.close(); - } catch (IOException e) { - // Ignore - } - } } } private void overwriteDataAndCheckForFlag(@NotNull FlatFileDatabaseManager targetDatabase, - @NotNull String[] data, @NotNull FlatFileDataFlag flag) { + @NotNull String[] data, + @NotNull FlatFileDataFlag expectedFlag) throws IOException { + // Given replaceDataInFile(targetDatabase, data); + // When List dataFlags = targetDatabase.checkFileHealthAndStructure(); + + // Then assertNotNull(dataFlags); - assertTrue(dataFlags.contains(flag)); + assertTrue(dataFlags.contains(expectedFlag)); } @NotNull @@ -861,4 +1229,35 @@ class FlatFileDatabaseManagerTest { directoryToBeDeleted.delete(); } -} \ No newline at end of file + private FlatFileDatabaseManager createDatabaseWithTwoRankedUsers() { + // Given – a fresh FlatFile DB + var databaseManager = new FlatFileDatabaseManager( + new File(getTemporaryUserFilePath()), logger, PURGE_TIME, 0, true); + + // Given – two users with different levels + UUID leaderUuid = randomUUID(); + UUID followerUuid = randomUUID(); + + databaseManager.newUser("leader", leaderUuid); + databaseManager.newUser("follower", followerUuid); + + var leaderProfile = databaseManager.loadPlayerProfile(leaderUuid); + var followerProfile = databaseManager.loadPlayerProfile(followerUuid); + + // Given – leader has higher levels in all non-child skills + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + if (isChildSkill(primarySkillType)) { + continue; + } + leaderProfile.modifySkill(primarySkillType, 100); + followerProfile.modifySkill(primarySkillType, 10); + } + + // When – save changes back to disk + databaseManager.saveUser(leaderProfile); + databaseManager.saveUser(followerProfile); + + return databaseManager; + } + +} diff --git a/src/test/java/com/gmail/nossr50/database/SQLDatabaseManagerTest.java b/src/test/java/com/gmail/nossr50/database/SQLDatabaseManagerTest.java index 959777ab9..7b2f7703a 100644 --- a/src/test/java/com/gmail/nossr50/database/SQLDatabaseManagerTest.java +++ b/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 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 mockedMcMMO; + private static GeneralConfig generalConfig; + private static AdvancedConfig advancedConfig; + private static UpgradeManager upgradeManager; + private static CompatibilityManager compatibilityManager; + private static SkillTools skillTools; + + // --- DB flavors you support --- + enum DbFlavor { + MYSQL, + MARIADB + } + + static Stream dbFlavors() { + return Stream.of(DbFlavor.MYSQL, DbFlavor.MARIADB); + } + + @BeforeAll + void setUpAll() { + // GIVEN a fully mocked mcMMO environment + compatibilityManager = mock(CompatibilityManager.class); + MinecraftGameVersion minecraftGameVersion = mock(MinecraftGameVersion.class); + when(compatibilityManager.getMinecraftGameVersion()).thenReturn(minecraftGameVersion); + when(minecraftGameVersion.isAtLeast(anyInt(), anyInt(), anyInt())).thenReturn(true); + + mockedMcMMO = Mockito.mockStatic(mcMMO.class); + mcMMO.p = Mockito.mock(mcMMO.class); + when(mcMMO.p.getLogger()).thenReturn(logger); + when(mcMMO.getCompatibilityManager()).thenReturn(compatibilityManager); + + mockGeneralConfigBase(); + + advancedConfig = Mockito.mock(AdvancedConfig.class); + when(mcMMO.p.getAdvancedConfig()).thenReturn(advancedConfig); + when(mcMMO.p.getAdvancedConfig().getStartingLevel()).thenReturn(0); + + skillTools = new SkillTools(mcMMO.p); + when(mcMMO.p.getSkillTools()).thenReturn(skillTools); + + compatibilityManager = Mockito.mock(CompatibilityManager.class); + when(mcMMO.getCompatibilityManager()).thenReturn(compatibilityManager); + when(compatibilityManager.getMinecraftGameVersion()) + .thenReturn(new MinecraftGameVersion(1, 20, 4)); + + upgradeManager = Mockito.mock(UpgradeManager.class); + when(mcMMO.getUpgradeManager()).thenReturn(upgradeManager); + when(mcMMO.getUpgradeManager().shouldUpgrade(any())).thenReturn(false); + + // Null player lookup, shouldn't affect tests + Server server = mock(Server.class); + when(mcMMO.p.getServer()).thenReturn(server); + when(server.getPlayerExact(anyString())) + .thenReturn(null); + } + + @AfterAll + static void tearDownAll() { + mockedMcMMO.close(); + } + + private static void mockGeneralConfigBase() { + generalConfig = Mockito.mock(GeneralConfig.class); + when(mcMMO.p.getGeneralConfig()).thenReturn(generalConfig); + + when(generalConfig.getLocale()).thenReturn("en_US"); + + // pool sizes + when(generalConfig.getMySQLMaxPoolSize(SQLDatabaseManager.PoolIdentifier.MISC)) + .thenReturn(10); + when(generalConfig.getMySQLMaxPoolSize(SQLDatabaseManager.PoolIdentifier.LOAD)) + .thenReturn(20); + when(generalConfig.getMySQLMaxPoolSize(SQLDatabaseManager.PoolIdentifier.SAVE)) + .thenReturn(20); + + // max connections + when(generalConfig.getMySQLMaxConnections(SQLDatabaseManager.PoolIdentifier.MISC)) + .thenReturn(30); + when(generalConfig.getMySQLMaxConnections(SQLDatabaseManager.PoolIdentifier.LOAD)) + .thenReturn(30); + when(generalConfig.getMySQLMaxConnections(SQLDatabaseManager.PoolIdentifier.SAVE)) + .thenReturn(30); + + // table prefix + when(generalConfig.getMySQLTablePrefix()).thenReturn("mcmmo_"); + + // public key retrieval + when(generalConfig.getMySQLPublicKeyRetrieval()).thenReturn(true); + + // use mysql + when(generalConfig.getUseMySQL()).thenReturn(true); + + // SSL effectively off for >= 1.17 + when(generalConfig.getMySQLSSL()).thenReturn(true); + + // mob health bar default + when(generalConfig.getMobHealthbarDefault()).thenReturn(MobHealthbarType.HEARTS); + } + + private JdbcDatabaseContainer containerFor(DbFlavor flavor) { + return switch (flavor) { + case MYSQL -> MYSQL_CONTAINER; + case MARIADB -> MARIADB_CONTAINER; + }; + } + + /** + * Wire the mcMMO GeneralConfig mocks to a specific running container, + * then construct a fresh SQLDatabaseManager using the MySQL driver + * (also works for MariaDB). + */ + private SQLDatabaseManager createManagerFor(DbFlavor flavor) { + JdbcDatabaseContainer container = containerFor(flavor); + + when(generalConfig.getMySQLServerName()).thenReturn(container.getHost()); + when(generalConfig.getMySQLServerPort()).thenReturn(container.getFirstMappedPort()); + when(generalConfig.getMySQLDatabaseName()).thenReturn(container.getDatabaseName()); + when(generalConfig.getMySQLUserName()).thenReturn(container.getUsername()); + when(generalConfig.getMySQLUserPassword()).thenReturn(container.getPassword()); + + return new SQLDatabaseManager(logger, "com.mysql.cj.jdbc.Driver"); + } + + /** + * Helper to wipe all core mcMMO SQL tables for a given DB flavor. + * This keeps tests isolated. + */ + private void truncateAllCoreTables(DbFlavor flavor) { + SQLDatabaseManager databaseManager = createManagerFor(flavor); + try (Connection connection = databaseManager.getConnection(SQLDatabaseManager.PoolIdentifier.MISC); + Statement statement = connection.createStatement()) { + + // Order matters because of foreign key constraints in some setups + // noinspection SqlWithoutWhere + statement.executeUpdate("DELETE FROM mcmmo_cooldowns"); + // noinspection SqlWithoutWhere + statement.executeUpdate("DELETE FROM mcmmo_experience"); + // noinspection SqlWithoutWhere + statement.executeUpdate("DELETE FROM mcmmo_huds"); + // noinspection SqlWithoutWhere + statement.executeUpdate("DELETE FROM mcmmo_skills"); + // noinspection SqlWithoutWhere + statement.executeUpdate("DELETE FROM mcmmo_users"); + } catch (SQLException exception) { + throw new RuntimeException("Failed to truncate core tables", exception); + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // Connection / basic wiring + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - getConnection for all pool identifiers") + @MethodSource("dbFlavors") + void whenGettingConnectionsForAllPoolsShouldReturnNonNullConnections(DbFlavor flavor) throws Exception { + // GIVEN a database manager for the selected flavor + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + try { + // WHEN requesting connections for all pool identifiers + for (SQLDatabaseManager.PoolIdentifier poolIdentifier : SQLDatabaseManager.PoolIdentifier.values()) { + Connection connection = databaseManager.getConnection(poolIdentifier); + + // THEN each connection should be non-null and open + assertThat(connection) + .as("Connection for pool %s should not be null", poolIdentifier) + .isNotNull(); + } + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // New user creation & initialization + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - newUser initializes skill levels and XP") + @MethodSource("dbFlavors") + void whenCreatingNewUserShouldInitializeSkillLevelsAndXpToStartingValues(DbFlavor flavor) { + // GIVEN a new player and database manager + SQLDatabaseManager databaseManager = createManagerFor(flavor); + Player player = Mockito.mock(Player.class); + UUID playerUuid = UUID.randomUUID(); + String playerName = "nossr50_" + flavor.name().toLowerCase(); + + when(player.getUniqueId()).thenReturn(playerUuid); + when(player.getName()).thenReturn(playerName); + + try { + // WHEN creating a new user + PlayerProfile playerProfile = databaseManager.newUser(player); + + // THEN the profile should be loaded with all skills and XP at starting values (0) + assertThat(playerProfile).isNotNull(); + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + assertThat(playerProfile.getSkillLevel(primarySkillType)) + .as("Skill level for %s", primarySkillType) + .isZero(); + assertThat(playerProfile.getSkillXpLevel(primarySkillType)) + .as("XP level for %s", primarySkillType) + .isZero(); + } + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // Saving skill levels / XP + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - saveUser persists skill level values") + @MethodSource("dbFlavors") + void whenSavingSkillLevelValuesShouldPersistToDatabase(DbFlavor flavor) { + // GIVEN a new user with modified skill levels + SQLDatabaseManager databaseManager = createManagerFor(flavor); + Player player = Mockito.mock(Player.class); + UUID playerUuid = UUID.randomUUID(); + String playerName = "nossr50_levels_" + flavor.name().toLowerCase(); + + when(player.getUniqueId()).thenReturn(playerUuid); + when(player.getName()).thenReturn(playerName); + + try { + PlayerProfile playerProfile = databaseManager.newUser(player); + + // AND all XP start at zero + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + assertThat(playerProfile.getSkillXpLevel(primarySkillType)) + .as("Initial XP for %s", primarySkillType) + .isZero(); + } + + // WHEN we modify levels and save + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + playerProfile.modifySkill(primarySkillType, 1 + primarySkillType.ordinal()); + } + + boolean saveSucceeded = databaseManager.saveUser(playerProfile); + + // THEN save should succeed + assertThat(saveSucceeded).isTrue(); + + // AND the retrieved user should have matching levels (except child skills) + PlayerProfile retrievedUser = databaseManager.loadPlayerProfile(player.getName()); + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + if (primarySkillType == PrimarySkillType.SALVAGE + || primarySkillType == PrimarySkillType.SMELTING) { + continue; + } + + assertThat(retrievedUser.getSkillLevel(primarySkillType)) + .as("Saved level for %s", primarySkillType) + .isEqualTo(1 + primarySkillType.ordinal()); + } + } finally { + databaseManager.onDisable(); + } + } + + @ParameterizedTest(name = "{0} - saveUser persists skill XP values") + @MethodSource("dbFlavors") + void whenSavingSkillXpValuesShouldPersistToDatabase(DbFlavor flavor) { + // GIVEN a new user with modified XP levels + SQLDatabaseManager databaseManager = createManagerFor(flavor); + Player player = Mockito.mock(Player.class); + UUID playerUuid = UUID.randomUUID(); + String playerName = "nossr50_xp_" + flavor.name().toLowerCase(); + + when(player.getUniqueId()).thenReturn(playerUuid); + when(player.getName()).thenReturn(playerName); + + try { + PlayerProfile playerProfile = databaseManager.newUser(player); + + // AND all XP start at zero + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + assertThat(playerProfile.getSkillXpLevel(primarySkillType)) + .as("Initial XP for %s", primarySkillType) + .isZero(); + } + + // WHEN we set XP values and save + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + playerProfile.setSkillXpLevel(primarySkillType, 1 + primarySkillType.ordinal()); + } + + boolean saveSucceeded = databaseManager.saveUser(playerProfile); + + // THEN save should succeed + assertThat(saveSucceeded).isTrue(); + + // AND the retrieved user should have matching XP (except child skills) + PlayerProfile retrievedUser = databaseManager.loadPlayerProfile(player.getName()); + for (PrimarySkillType primarySkillType : PrimarySkillType.values()) { + if (primarySkillType == PrimarySkillType.SALVAGE + || primarySkillType == PrimarySkillType.SMELTING) { + continue; + } + + assertThat(retrievedUser.getSkillXpLevel(primarySkillType)) + .as("Saved XP for %s", primarySkillType) + .isEqualTo(1 + primarySkillType.ordinal()); + } + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // Schema upgrades + // ------------------------------------------------------------------------ + + @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 uuidUpdates = new HashMap<>(); + UUID firstNewUuid = UUID.randomUUID(); + UUID secondNewUuid = UUID.randomUUID(); + uuidUpdates.put(firstUsername, firstNewUuid); + uuidUpdates.put(secondUsername, secondNewUuid); + + try { + // WHEN performing a bulk UUID update + boolean updateSucceeded = databaseManager.saveUserUUIDs(uuidUpdates); + + // THEN the update should succeed + assertThat(updateSucceeded).isTrue(); + + // AND both rows should reflect the new UUID values + JdbcDatabaseContainer container = containerFor(flavor); + try (Connection connection = DriverManager.getConnection( + container.getJdbcUrl(), container.getUsername(), container.getPassword()); + Statement statement = connection.createStatement()) { + + try (ResultSet resultSet = statement.executeQuery( + "SELECT user, uuid FROM mcmmo_users WHERE user IN ('" + firstUsername + "','" + + secondUsername + "')")) { + int rowsSeen = 0; + while (resultSet.next()) { + String user = resultSet.getString("user"); + String uuid = resultSet.getString("uuid"); + if (user.equals(firstUsername)) { + assertThat(uuid).isEqualTo(firstNewUuid.toString()); + rowsSeen++; + } else if (user.equals(secondUsername)) { + assertThat(uuid).isEqualTo(secondNewUuid.toString()); + rowsSeen++; + } + } + assertThat(rowsSeen).isEqualTo(2); + } + } + + // AND getStoredUsers still contains both names + var storedUsers = databaseManager.getStoredUsers(); + assertThat(storedUsers).contains(firstUsername, secondUsername); + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // purgePowerlessUsers + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - purgePowerlessUsers removes only zero-skill users") + @MethodSource("dbFlavors") + void whenPurgingPowerlessUsersShouldRemoveOnlyZeroSkillUsers(DbFlavor flavor) throws Exception { + // GIVEN one powerless user (all skills zero) and one powered user + SQLDatabaseManager databaseManager = createManagerFor(flavor); + truncateAllCoreTables(flavor); + + Player powerlessPlayer = Mockito.mock(Player.class); + when(powerlessPlayer.getUniqueId()).thenReturn(UUID.randomUUID()); + when(powerlessPlayer.getName()).thenReturn("powerless_" + flavor.name().toLowerCase()); + databaseManager.newUser(powerlessPlayer); + + Player poweredPlayer = Mockito.mock(Player.class); + UUID poweredUuid = UUID.randomUUID(); + when(poweredPlayer.getUniqueId()).thenReturn(poweredUuid); + when(poweredPlayer.getName()).thenReturn("powered_" + flavor.name().toLowerCase()); + PlayerProfile poweredProfile = databaseManager.newUser(poweredPlayer); + poweredProfile.modifySkill(PrimarySkillType.MINING, 10); + assertThat(databaseManager.saveUser(poweredProfile)).isTrue(); + + // WHEN purging powerless users + int purgedCount = databaseManager.purgePowerlessUsers(); + + // THEN exactly one user should be purged + assertThat(purgedCount) + .as("Exactly one powerless user should be purged") + .isEqualTo(1); + + JdbcDatabaseContainer container = containerFor(flavor); + try (Connection connection = DriverManager.getConnection( + container.getJdbcUrl(), container.getUsername(), container.getPassword()); + Statement statement = connection.createStatement()) { + + // AND powerless user should be gone + try (ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM mcmmo_users WHERE user = '" + powerlessPlayer.getName() + "'")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)).isZero(); + } + + // AND powered user should still exist + try (ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM mcmmo_users WHERE user = '" + poweredPlayer.getName() + "'")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)).isEqualTo(1); + } + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // Missing user / fallback behavior + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - loadPlayerProfile(missing name) returns empty profile with zero skills") + @MethodSource("dbFlavors") + void whenLoadingMissingUserByNameShouldReturnEmptyProfileWithZeroSkills(DbFlavor flavor) { + // GIVEN an empty database + SQLDatabaseManager databaseManager = createManagerFor(flavor); + truncateAllCoreTables(flavor); + + String ghostName = "ghost_" + flavor.name().toLowerCase(); + + try { + // WHEN loading a profile by a missing username + PlayerProfile profile = databaseManager.loadPlayerProfile(ghostName); + + // THEN profile should not be null, and all skill levels should be zero + assertThat(profile).isNotNull(); + for (PrimarySkillType type : PrimarySkillType.values()) { + assertThat(profile.getSkillLevel(type)) + .as("Expected skill level 0 for %s on missing user profile", type) + .isZero(); + } + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // Mob health HUD reset + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - resetMobHealthSettings sets mobhealthbar to default for all users") + @MethodSource("dbFlavors") + void whenResettingMobHealthSettingsShouldResetAllHudRowsToDefault(DbFlavor flavor) throws Exception { + // GIVEN multiple users with non-default mobhealthbar values + SQLDatabaseManager databaseManager = createManagerFor(flavor); + truncateAllCoreTables(flavor); + + databaseManager.newUser("hudguy1_" + flavor.name().toLowerCase(), UUID.randomUUID()); + databaseManager.newUser("hudguy2_" + flavor.name().toLowerCase(), UUID.randomUUID()); + + try (Connection connection = databaseManager.getConnection(SQLDatabaseManager.PoolIdentifier.MISC); + Statement statement = connection.createStatement()) { + + statement.executeUpdate("UPDATE mcmmo_huds SET mobhealthbar = 'SOMETHING_ELSE'"); + } + + try { + // WHEN resetMobHealthSettings is invoked + databaseManager.resetMobHealthSettings(); + + // THEN all HUD rows should have the default mobhealthbar type + try (Connection connection = databaseManager.getConnection(SQLDatabaseManager.PoolIdentifier.MISC); + Statement statement = connection.createStatement(); + ResultSet resultSet = statement.executeQuery("SELECT DISTINCT mobhealthbar FROM mcmmo_huds")) { + + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getString(1)).isEqualTo(MobHealthbarType.HEARTS.name()); + assertThat(resultSet.next()) + .as("Only one distinct mobhealthbar value should remain") + .isFalse(); + } + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // loadPlayerProfile by name / UUID / Player + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - loadPlayerProfile(name)") + @MethodSource("dbFlavors") + void whenLoadingByNameShouldReturnMatchingProfile(DbFlavor flavor) { + // GIVEN a persisted user + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + String playerName = "nossr50_" + flavor.name().toLowerCase() + "_byName"; + UUID uuid = UUID.randomUUID(); + + try { + PlayerProfile createdProfile = databaseManager.newUser(playerName, uuid); + assertThat(createdProfile.isLoaded()).isTrue(); + + // WHEN loading by name + PlayerProfile loadedProfile = databaseManager.loadPlayerProfile(playerName); + + // THEN the loaded profile should match the persisted data + assertThat(loadedProfile.isLoaded()).isTrue(); + assertThat(loadedProfile.getPlayerName()).isEqualTo(playerName); + assertThat(loadedProfile.getUniqueId()).isEqualTo(uuid); + } finally { + databaseManager.onDisable(); + } + } + + @ParameterizedTest(name = "{0} - loadPlayerProfile(uuid)") + @MethodSource("dbFlavors") + void whenLoadingByUuidShouldReturnMatchingProfileAndUnknownUuidShouldReturnUnloadedProfile(DbFlavor flavor) { + // GIVEN a persisted user + truncateAllCoreTables(flavor); + final SQLDatabaseManager databaseManager = createManagerFor(flavor); + + final String playerName = "nossr50_" + flavor.name().toLowerCase() + "_byUuid"; + final UUID uuid = UUID.randomUUID(); + + try { + PlayerProfile newlyCreatedUser = databaseManager.newUser(playerName, uuid); + databaseManager.saveUser(newlyCreatedUser); + + // WHEN loading by the correct UUID + PlayerProfile loadedProfile = databaseManager.loadPlayerProfile(uuid, "tEmPnAmE"); + + // THEN the profile should be loaded and match + assertThat(loadedProfile.isLoaded()).isTrue(); + assertThat(loadedProfile.getPlayerName()).isEqualTo("tEmPnAmE"); + assertThat(loadedProfile.getUniqueId()).isEqualTo(uuid); + + // AND loading by an unknown UUID should return an unloaded profile + PlayerProfile unknownProfile = databaseManager.loadPlayerProfile(UUID.randomUUID()); + assertThat(unknownProfile.isLoaded()).isFalse(); + } finally { + databaseManager.onDisable(); + } + } + + @ParameterizedTest(name = "{0} - loadPlayerProfile(Player) updates username") + @MethodSource("dbFlavors") + void whenLoadingByPlayerShouldUpdateUsernameForExistingUuid(DbFlavor flavor) { + // GIVEN a user persisted under an original name + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + String originalName = "nossr50_original_" + flavor.name().toLowerCase(); + UUID uuid = UUID.randomUUID(); + + try { + databaseManager.newUser(originalName, uuid); + + // AND a Player with the same UUID but an updated name + String updatedName = "nossr50_updated_" + flavor.name().toLowerCase(); + Player player = Mockito.mock(Player.class); + when(player.getUniqueId()).thenReturn(uuid); + when(player.getName()).thenReturn(updatedName); + + // WHEN loading via Player + PlayerProfile updatedProfile = databaseManager.loadPlayerProfile(player); + + // THEN the profile should reflect the new name + assertThat(updatedProfile.isLoaded()).isTrue(); + assertThat(updatedProfile.getPlayerName()).isEqualTo(updatedName); + assertThat(updatedProfile.getUniqueId()).isEqualTo(uuid); + + // AND loading by new name should work + PlayerProfile byNewName = databaseManager.loadPlayerProfile(updatedName); + assertThat(byNewName.isLoaded()).isTrue(); + assertThat(byNewName.getPlayerName()).isEqualTo(updatedName); + assertThat(byNewName.getUniqueId()).isEqualTo(uuid); + + // AND loading by old name should now return an unloaded profile + PlayerProfile byOldName = databaseManager.loadPlayerProfile(originalName); + assertThat(byOldName.isLoaded()).isFalse(); + } finally { + databaseManager.onDisable(); + } + } + + @ParameterizedTest(name = "{0} - loadPlayerProfile(name) data not found") + @MethodSource("dbFlavors") + void whenLoadingNonExistentPlayerByNameShouldReturnUnloadedProfile(DbFlavor flavor) { + // GIVEN an empty database + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + try { + // WHEN loading a non-existent player by name + PlayerProfile profile = databaseManager.loadPlayerProfile("nonexistent_" + flavor.name().toLowerCase()); + + // THEN the profile should not be loaded + assertThat(profile.isLoaded()).isFalse(); + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // removeUser + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - removeUser") + @MethodSource("dbFlavors") + void whenRemovingUserShouldDeleteOnlySpecifiedUser(DbFlavor flavor) { + // GIVEN two persisted users + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + String keepName = "keepme_" + flavor.name().toLowerCase(); + UUID keepUuid = UUID.randomUUID(); + databaseManager.newUser(keepName, keepUuid); + + String deleteName = "deleteme_" + flavor.name().toLowerCase(); + UUID deleteUuid = UUID.randomUUID(); + databaseManager.newUser(deleteName, deleteUuid); + + try { + // AND both users exist + assertThat(databaseManager.loadPlayerProfile(keepUuid).isLoaded()).isTrue(); + assertThat(databaseManager.loadPlayerProfile(deleteUuid).isLoaded()).isTrue(); + + // WHEN removing the delete user + boolean firstRemovalSucceeded = databaseManager.removeUser(deleteName, deleteUuid); + + // THEN the first removal should succeed and the user should be gone + assertThat(firstRemovalSucceeded).isTrue(); + assertThat(databaseManager.loadPlayerProfile(deleteUuid).isLoaded()).isFalse(); + + // AND a second removal should fail + boolean secondRemovalSucceeded = databaseManager.removeUser(deleteName, deleteUuid); + assertThat(secondRemovalSucceeded).isFalse(); + + // AND the keep user should still exist + assertThat(databaseManager.loadPlayerProfile(keepUuid).isLoaded()).isTrue(); + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // purgeOldUsers + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - purgeOldUsers") + @MethodSource("dbFlavors") + void whenPurgingOldUsersShouldRemoveOnlyOutdatedUsers(DbFlavor flavor) throws Exception { + // GIVEN one old user and one recent user + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + when(mcMMO.p.getPurgeTime()).thenReturn(10L); + + String oldName = "old_" + flavor.name().toLowerCase(); + UUID oldUuid = UUID.randomUUID(); + databaseManager.newUser(oldName, oldUuid); + + String recentName = "recent_" + flavor.name().toLowerCase(); + UUID recentUuid = UUID.randomUUID(); + databaseManager.newUser(recentName, recentUuid); + + try (Connection connection = databaseManager.getConnection(SQLDatabaseManager.PoolIdentifier.MISC); + Statement statement = connection.createStatement()) { + + statement.executeUpdate("UPDATE mcmmo_users SET lastlogin = 0 WHERE `user` = '" + oldName + "'"); + statement.executeUpdate( + "UPDATE mcmmo_users SET lastlogin = UNIX_TIMESTAMP() WHERE `user` = '" + recentName + "'"); + } + + try { + // WHEN purgeOldUsers is invoked + databaseManager.purgeOldUsers(); + + // THEN old user should be removed + PlayerProfile oldProfile = databaseManager.loadPlayerProfile(oldUuid); + assertThat(oldProfile.isLoaded()) + .as("Old user should have been purged") + .isFalse(); + + // AND recent user should remain + PlayerProfile recentProfile = databaseManager.loadPlayerProfile(recentUuid); + assertThat(recentProfile.isLoaded()) + .as("Recent user should remain") + .isTrue(); + } finally { + databaseManager.onDisable(); + } + } + + // ------------------------------------------------------------------------ + // readRank + // ------------------------------------------------------------------------ + + @ParameterizedTest(name = "{0} - readRank") + @MethodSource("dbFlavors") + void whenReadingRankShouldReturnExpectedPositions(DbFlavor flavor) { + // GIVEN two users with different levels + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + String rankGirlName = "rankGirl_" + flavor.name().toLowerCase(); + UUID rankGirlUuid = new UUID(1337L, 1337L); + + String rankBoyName = "rankBoy_" + flavor.name().toLowerCase(); + UUID rankBoyUuid = new UUID(7331L, 7331L); + + try { + databaseManager.newUser(rankGirlName, rankGirlUuid); + PlayerProfile girlProfile = databaseManager.loadPlayerProfile(rankGirlUuid); + for (PrimarySkillType type : PrimarySkillType.values()) { + if (SkillTools.isChildSkill(type)) { + continue; + } + girlProfile.modifySkill(type, 100); + } + assertThat(databaseManager.saveUser(girlProfile)).isTrue(); + + databaseManager.newUser(rankBoyName, rankBoyUuid); + PlayerProfile boyProfile = databaseManager.loadPlayerProfile(rankBoyUuid); + for (PrimarySkillType type : PrimarySkillType.values()) { + if (SkillTools.isChildSkill(type)) { + continue; + } + boyProfile.modifySkill(type, 10); + } + assertThat(databaseManager.saveUser(boyProfile)).isTrue(); + + // WHEN reading rank for both users + Map girlRanks = databaseManager.readRank(rankGirlName); + Map boyRanks = databaseManager.readRank(rankBoyName); + + // THEN girl should be rank 1, boy rank 2 for all non-child skills + for (PrimarySkillType type : PrimarySkillType.values()) { + if (SkillTools.isChildSkill(type)) { + assertThat(girlRanks.get(type)).isNull(); + assertThat(boyRanks.get(type)).isNull(); + } else { + assertThat(girlRanks.get(type)).isEqualTo(1); + assertThat(boyRanks.get(type)).isEqualTo(2); + } + } + + // AND total ranking (null key) should be 1 and 2 respectively + assertThat(girlRanks.get(null)).isEqualTo(1); + assertThat(boyRanks.get(null)).isEqualTo(2); + } finally { + databaseManager.onDisable(); + } + } + + @ParameterizedTest(name = "{0} - readLeaderboard(MINING) returns users in descending order") + @MethodSource("dbFlavors") + void whenReadingLeaderboardForMiningShouldReturnUsersOrderedBySkillDescending(DbFlavor flavor) throws Exception { + // GIVEN + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + String topPlayerName = "leader_top_" + flavor.name().toLowerCase(); + UUID topUuid = UUID.randomUUID(); + databaseManager.newUser(topPlayerName, topUuid); + + String lowerPlayerName = "leader_low_" + flavor.name().toLowerCase(); + UUID lowerUuid = UUID.randomUUID(); + databaseManager.newUser(lowerPlayerName, lowerUuid); + + PlayerProfile topProfile = databaseManager.loadPlayerProfile(topUuid); + PlayerProfile lowerProfile = databaseManager.loadPlayerProfile(lowerUuid); + + // GIVEN – mining levels: top > low + topProfile.modifySkill(PrimarySkillType.MINING, 200); + lowerProfile.modifySkill(PrimarySkillType.MINING, 50); + + assertThat(databaseManager.saveUser(topProfile)).isTrue(); + assertThat(databaseManager.saveUser(lowerProfile)).isTrue(); + + // WHEN + List miningStats = + databaseManager.readLeaderboard(PrimarySkillType.MINING, 1, 10); + + // THEN + assertThat(miningStats) + .extracting(PlayerStat::playerName) + .containsExactly(topPlayerName, lowerPlayerName); + + assertThat(miningStats) + .extracting(PlayerStat::value) + .containsExactly(200, 50); + + databaseManager.onDisable(); + } + + @ParameterizedTest(name = "{0} - readLeaderboard(null) uses total column") + @MethodSource("dbFlavors") + void whenReadingLeaderboardForTotalShouldUseTotalColumn(DbFlavor flavor) throws Exception { + // GIVEN + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + String topPlayerName = "leader_total_top_" + flavor.name().toLowerCase(); + UUID topUuid = UUID.randomUUID(); + databaseManager.newUser(topPlayerName, topUuid); + + String lowerPlayerName = "leader_total_low_" + flavor.name().toLowerCase(); + UUID lowerUuid = UUID.randomUUID(); + databaseManager.newUser(lowerPlayerName, lowerUuid); + + PlayerProfile topProfile = databaseManager.loadPlayerProfile(topUuid); + PlayerProfile lowerProfile = databaseManager.loadPlayerProfile(lowerUuid); + + // GIVEN – only MINING changed, but total is recomputed in updateSkills() + topProfile.modifySkill(PrimarySkillType.MINING, 300); + lowerProfile.modifySkill(PrimarySkillType.MINING, 100); + + assertThat(databaseManager.saveUser(topProfile)).isTrue(); + assertThat(databaseManager.saveUser(lowerProfile)).isTrue(); + + // WHEN – null skill → ALL_QUERY_VERSION ("total") + List totalStats = databaseManager.readLeaderboard(null, 1, 10); + + // THEN + assertThat(totalStats) + .extracting(PlayerStat::playerName) + .containsExactly(topPlayerName, lowerPlayerName); + + assertThat(totalStats) + .extracting(PlayerStat::value) + .containsExactly(300, 100); + + databaseManager.onDisable(); + } + + @ParameterizedTest(name = "{0} - readLeaderboard(child skill) throws InvalidSkillException") + @MethodSource("dbFlavors") + void whenReadingLeaderboardForChildSkillShouldThrowInvalidSkillException(DbFlavor flavor) { + // GIVEN + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + // WHEN / THEN + assertThatThrownBy(() -> + databaseManager.readLeaderboard(PrimarySkillType.SALVAGE, 1, 10)) + .isInstanceOf(InvalidSkillException.class) + .hasMessageContaining("child skills do not have leaderboards"); + + databaseManager.onDisable(); + } + + @ParameterizedTest(name = "{0} - readRank for unknown user returns empty map") + @MethodSource("dbFlavors") + void whenReadingRankForUnknownUserShouldReturnEmptyMap(DbFlavor flavor) { + truncateAllCoreTables(flavor); + SQLDatabaseManager databaseManager = createManagerFor(flavor); + + try { + Map 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 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 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 skillMap = Map.of(PrimarySkillType.MINING, 100); + + createUserWithSkills(databaseManager, nameA, uuidA, skillMap); + createUserWithSkills(databaseManager, nameB, uuidB, skillMap); + createUserWithSkills(databaseManager, nameC, uuidC, skillMap); + + Map ranksA = databaseManager.readRank(nameA); + Map ranksB = databaseManager.readRank(nameB); + Map 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 tieSkills = Map.of(PrimarySkillType.MINING, 100); + createUserWithSkills(databaseManager, nameA, uuidA, tieSkills); + createUserWithSkills(databaseManager, nameB, uuidB, tieSkills); + createUserWithSkills(databaseManager, nameC, uuidC, tieSkills); + + Map higherRanks = databaseManager.readRank(higherName); + Map ranksA = databaseManager.readRank(nameA); + Map ranksB = databaseManager.readRank(nameB); + Map 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 alphaRanks = databaseManager.readRank(alphaName); + Map bravoRanks = databaseManager.readRank(bravoName); + Map 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 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 levels) { + manager.newUser(name, uuid); + PlayerProfile profile = manager.loadPlayerProfile(uuid); + for (Map.Entry e : levels.entrySet()) { + // modifySkill adds; starting level is 0 in tests + profile.modifySkill(e.getKey(), e.getValue()); + } + assertThat(manager.saveUser(profile)).isTrue(); + } + +} diff --git a/src/test/java/com/gmail/nossr50/util/skills/SkillToolsTest.java b/src/test/java/com/gmail/nossr50/util/skills/SkillToolsTest.java new file mode 100644 index 000000000..0d7f43715 --- /dev/null +++ b/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 mockedMcMMO; + private static MockedStatic 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 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 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 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 sorted = new ArrayList<>(names); + Collections.sort(sorted); + assertThat(names).isEqualTo(sorted); + } +} diff --git a/src/test/resources/healthydb.users b/src/test/resources/healthydb.users index c2561b38d..72be21ed3 100644 --- a/src/test/resources/healthydb.users +++ b/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: \ No newline at end of file +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: \ No newline at end of file