diff --git a/Changelog.txt b/Changelog.txt index 820d4a50c..8b89e168c 100644 --- a/Changelog.txt +++ b/Changelog.txt @@ -3,12 +3,14 @@ Version 2.2.046 Added permissions related to Spears Added /spears skill command Fixed bug where converting from SQL to FlatFile would not copy data for tridents, crossbows, maces, or spears - (Codebase) Added dockerized unit tests for SQL databases - (Codebase) Large refactoring to SQLDatabaseManager to bring it up to modern standards and improve maintainability + (Codebase) Added dockerized 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: - If you manually compile mcMMO you will need docker to run the unit tests, if you'd rather not install docker simply just add -DskipTests to your maven instructions - + If you compile mcMMO you will likely run into errors during unit tests for SQL databases, this is because they now rely on docker being present on the system to load up test containers. + New SQL database unit tests have been added which leverage test containers to test against real installs of MySQL/MariaDB, which require Docker (or an equivalent) to run. + If you'd rather not install docker simply just add -DskipTests to your maven instructions when compiling, this doesn't change anything about mcMMO it just skips running through the unit tests during the build. Version 2.2.045 Green Thumb now replants some crops it was failing to replant before (see notes) 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 f6b4b2381..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,8 @@ 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; @@ -35,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; @@ -102,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) { @@ -112,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(); @@ -121,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) { @@ -592,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); } } @@ -629,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) { @@ -645,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); @@ -663,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) { @@ -676,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; } @@ -737,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; } @@ -790,468 +656,293 @@ 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) { int convertedUsers = 0; long startMillis = System.currentTimeMillis(); synchronized (fileWritingLock) { - try (BufferedReader reader = new BufferedReader(new FileReader(usersFilePath))) { + try (BufferedReader reader = newBufferedReader()) { String line; while ((line = reader.readLine()) != null) { line = line.trim(); - // Skip comments and empty lines if (line.isEmpty() || line.startsWith("#")) { continue; } - final String[] character = line.split(":"); + String[] character = line.split(":"); try { destination.saveUser(loadFromLine(character)); } catch (Exception e) { - // Keep the same semantics as before, but log via logger - final String username = (character.length > USERNAME_INDEX) + String username = (character.length > USERNAME_INDEX) ? character[USERNAME_INDEX] : ""; - logger.log( - Level.SEVERE, - "Could not convert user from FlatFile to SQL DB: " + username, - e - ); + logger.log(Level.SEVERE, + "Could not convert user from FlatFile to SQL DB: " + username, e); } convertedUsers++; Misc.printProgress(convertedUsers, progressInterval, startMillis); } } catch (IOException e) { - logger.log(Level.SEVERE, "Failed to convert users from FlatFile to SQL DB", 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) { @@ -1260,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; } @@ -1277,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) { @@ -1370,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) { @@ -1386,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( @@ -1404,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); } } @@ -1418,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; } @@ -1482,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/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/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/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