From e8328ba10c5fb54b5e6f65c36e24b335bdae9efb Mon Sep 17 00:00:00 2001 From: Krakenied Date: Sun, 18 Aug 2024 09:03:51 +0200 Subject: [PATCH] Storage rework --- .../quests/bukkit/BukkitQuestsPlugin.java | 8 +- .../command/AdminMigrateCommandHandler.java | 15 +- .../AdminModdataCompleteCommandHandler.java | 2 +- .../AdminModdataFullresetCommandHandler.java | 2 +- .../AdminModdataRandomCommandHandler.java | 2 +- .../AdminModdataResetCommandHandler.java | 2 +- .../AdminModdataStartCommandHandler.java | 2 +- .../storage/ModernMySQLStorageProvider.java | 597 ++++++++++++++++++ .../storage/ModernYAMLStorageProvider.java | 284 +++++++++ .../bukkit/storage/MySqlStorageProvider.java | 471 -------------- .../bukkit/storage/YamlStorageProvider.java | 212 ------- .../quests/bukkit/util/CommandUtils.java | 27 +- .../resources/resources/bukkit/config.yml | 64 +- .../quests/common/player/QPlayer.java | 118 ++-- .../quests/common/player/QPlayerData.java | 44 ++ .../quests/common/player/QPlayerManager.java | 145 +++-- .../questprogressfile/QuestProgress.java | 344 +++++++--- .../questprogressfile/QuestProgressFile.java | 414 +++++++----- .../questprogressfile/TaskProgress.java | 151 +++-- .../common/storage/StorageProvider.java | 82 ++- 20 files changed, 1857 insertions(+), 1129 deletions(-) create mode 100644 bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/ModernMySQLStorageProvider.java create mode 100644 bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/ModernYAMLStorageProvider.java delete mode 100644 bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/MySqlStorageProvider.java delete mode 100644 bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/YamlStorageProvider.java create mode 100644 common/src/main/java/com/leonardobishop/quests/common/player/QPlayerData.java diff --git a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/BukkitQuestsPlugin.java b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/BukkitQuestsPlugin.java index bafe5381e..e8a2ae57a 100644 --- a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/BukkitQuestsPlugin.java +++ b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/BukkitQuestsPlugin.java @@ -59,8 +59,8 @@ import com.leonardobishop.quests.bukkit.scheduler.WrappedTask; import com.leonardobishop.quests.bukkit.scheduler.bukkit.BukkitServerSchedulerAdapter; import com.leonardobishop.quests.bukkit.scheduler.folia.FoliaServerScheduler; -import com.leonardobishop.quests.bukkit.storage.MySqlStorageProvider; -import com.leonardobishop.quests.bukkit.storage.YamlStorageProvider; +import com.leonardobishop.quests.bukkit.storage.ModernMySQLStorageProvider; +import com.leonardobishop.quests.bukkit.storage.ModernYAMLStorageProvider; import com.leonardobishop.quests.bukkit.tasktype.BukkitTaskTypeManager; import com.leonardobishop.quests.bukkit.tasktype.type.BarteringTaskType; import com.leonardobishop.quests.bukkit.tasktype.type.BlockItemdroppingTaskType; @@ -291,14 +291,14 @@ public void onEnable() { default: questsLogger.warning("No valid storage provider is configured - Quests will use YAML storage as a default"); case "yaml": - this.storageProvider = new YamlStorageProvider(this); + this.storageProvider = new ModernYAMLStorageProvider(this); break; case "mysql": ConfigurationSection section = this.getConfig().getConfigurationSection("options.storage.database-settings"); if (section == null) { questsLogger.warning("No database settings are configured - default values will be used"); } - this.storageProvider = new MySqlStorageProvider(this, section); + this.storageProvider = new ModernMySQLStorageProvider(this, section); } try { diff --git a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminMigrateCommandHandler.java b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminMigrateCommandHandler.java index 2f3ad0af5..7385bad17 100644 --- a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminMigrateCommandHandler.java +++ b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminMigrateCommandHandler.java @@ -1,8 +1,9 @@ package com.leonardobishop.quests.bukkit.command; import com.leonardobishop.quests.bukkit.BukkitQuestsPlugin; -import com.leonardobishop.quests.bukkit.storage.MySqlStorageProvider; -import com.leonardobishop.quests.bukkit.storage.YamlStorageProvider; +import com.leonardobishop.quests.bukkit.storage.ModernMySQLStorageProvider; +import com.leonardobishop.quests.bukkit.storage.ModernYAMLStorageProvider; +import com.leonardobishop.quests.common.player.QPlayerData; import com.leonardobishop.quests.common.player.questprogressfile.QuestProgressFile; import com.leonardobishop.quests.common.storage.StorageProvider; import org.bukkit.ChatColor; @@ -87,15 +88,15 @@ public void handle(CommandSender sender, String[] args) { } sender.sendMessage(ChatColor.GRAY + "Loading quest progress files from '" + fromProvider.getName() + "'..."); - List files = fromProvider.loadAllProgressFiles(); + List files = fromProvider.loadAllPlayerData(); sender.sendMessage(ChatColor.GRAY.toString() + files.size() + " files loaded."); - for (QuestProgressFile file : files) { + for (QPlayerData file : files) { file.setModified(true); } sender.sendMessage(ChatColor.GRAY + "Writing quest progress files to '" + toProvider.getName() + "'..."); - toProvider.saveAllProgressFiles(files); + toProvider.saveAllPlayerData(files); sender.sendMessage(ChatColor.GRAY + "Done."); shutdownProvider(sender, fromProvider); @@ -151,11 +152,11 @@ private StorageProvider getStorageProvider(ConfigurationSection configurationSec switch (configuredProvider.toLowerCase()) { default: case "yaml": - storageProvider = new YamlStorageProvider(plugin); + storageProvider = new ModernYAMLStorageProvider(plugin); break; case "mysql": ConfigurationSection section = configurationSection.getConfigurationSection("database-settings"); - storageProvider = new MySqlStorageProvider(plugin, section); + storageProvider = new ModernMySQLStorageProvider(plugin, section); } return storageProvider; } diff --git a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataCompleteCommandHandler.java b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataCompleteCommandHandler.java index 8f85f8189..2f8aa8bc3 100644 --- a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataCompleteCommandHandler.java +++ b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataCompleteCommandHandler.java @@ -34,7 +34,7 @@ public void handle(CommandSender sender, String[] args) { qPlayer.completeQuest(quest); Messages.COMMAND_QUEST_ADMIN_COMPLETE_SUCCESS.send(sender, "{player}", args[3], "{quest}", quest.getId()); - CommandUtils.doSafeSave(qPlayer, questProgressFile, plugin); + CommandUtils.doSafeSave(this.plugin, qPlayer); }); return; } diff --git a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataFullresetCommandHandler.java b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataFullresetCommandHandler.java index 9703971df..885b155bc 100644 --- a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataFullresetCommandHandler.java +++ b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataFullresetCommandHandler.java @@ -27,7 +27,7 @@ public void handle(CommandSender sender, String[] args) { questProgressFile.reset(); Messages.COMMAND_QUEST_ADMIN_FULLRESET.send(sender, "{player}", args[3]); - CommandUtils.doSafeSave(qPlayer, questProgressFile, plugin); + CommandUtils.doSafeSave(this.plugin, qPlayer); }); return; diff --git a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataRandomCommandHandler.java b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataRandomCommandHandler.java index 81557cc52..81a5759a5 100644 --- a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataRandomCommandHandler.java +++ b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataRandomCommandHandler.java @@ -78,7 +78,7 @@ public void handle(CommandSender sender, String[] args) { "{quest}", quest.getId()); } - CommandUtils.doSafeSave(qPlayer, questProgressFile, plugin); + CommandUtils.doSafeSave(this.plugin, qPlayer); }); } diff --git a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataResetCommandHandler.java b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataResetCommandHandler.java index c57d06ed4..6355045e7 100644 --- a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataResetCommandHandler.java +++ b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataResetCommandHandler.java @@ -34,7 +34,7 @@ public void handle(CommandSender sender, String[] args) { questProgressFile.generateBlankQuestProgress(quest, true); Messages.COMMAND_QUEST_ADMIN_RESET_SUCCESS.send(sender, "{player}", args[3], "{quest}", quest.getId()); - CommandUtils.doSafeSave(qPlayer, questProgressFile, plugin); + CommandUtils.doSafeSave(this.plugin, qPlayer); }); return; } diff --git a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataStartCommandHandler.java b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataStartCommandHandler.java index 5b54cd536..15626726b 100644 --- a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataStartCommandHandler.java +++ b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/command/AdminModdataStartCommandHandler.java @@ -59,7 +59,7 @@ public void handle(CommandSender sender, String[] args) { Messages.COMMAND_QUEST_ADMIN_START_SUCCESS.send(sender, "{player}", args[3], "{quest}", quest.getId()); - CommandUtils.doSafeSave(qPlayer, questProgressFile, plugin); + CommandUtils.doSafeSave(this.plugin, qPlayer); }); return; } diff --git a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/ModernMySQLStorageProvider.java b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/ModernMySQLStorageProvider.java new file mode 100644 index 000000000..4eb8cba24 --- /dev/null +++ b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/ModernMySQLStorageProvider.java @@ -0,0 +1,597 @@ +package com.leonardobishop.quests.bukkit.storage; + +import com.leonardobishop.quests.bukkit.BukkitQuestsPlugin; +import com.leonardobishop.quests.common.player.QPlayerData; +import com.leonardobishop.quests.common.player.questprogressfile.QuestProgress; +import com.leonardobishop.quests.common.player.questprogressfile.QuestProgressFile; +import com.leonardobishop.quests.common.player.questprogressfile.TaskProgress; +import com.leonardobishop.quests.common.quest.Quest; +import com.leonardobishop.quests.common.quest.Task; +import com.leonardobishop.quests.common.storage.StorageProvider; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.logging.Level; + +public final class ModernMySQLStorageProvider implements StorageProvider { + + // Table creation SQL + private static final String CREATE_TABLE_QUEST_PROGRESS = + "CREATE TABLE IF NOT EXISTS `{prefix}quest_progress` (" + + " `uuid` VARCHAR(36) NOT NULL," + + " `quest_id` VARCHAR(50) NOT NULL," + + " `started` BOOL NOT NULL," + + " `started_date` BIGINT NOT NULL," + + " `completed` BOOL NOT NULL," + + " `completed_before` BOOL NOT NULL," + + " `completion_date` BIGINT NOT NULL," + + " PRIMARY KEY (`uuid`, `quest_id`));"; + private static final String CREATE_TABLE_TASK_PROGRESS = + "CREATE TABLE IF NOT EXISTS `{prefix}task_progress` (" + + " `uuid` VARCHAR(36) NOT NULL," + + " `quest_id` VARCHAR(50) NOT NULL," + + " `task_id` VARCHAR(50) NOT NULL," + + " `completed` BOOL NOT NULL," + + " `progress` VARCHAR(64) NULL," + + " `data_type` VARCHAR(10) NULL," + + " PRIMARY KEY (`uuid`, `quest_id`, `task_id`));"; + private static final String CREATE_TABLE_PLAYER_PREFERENCES = + "CREATE TABLE IF NOT EXISTS `{prefix}player_preferences` (" + + " `uuid` CHAR(36) NOT NULL," + + " `preference_id` VARCHAR(255) NOT NULL," + + " `value` VARCHAR(64) NULL," + + " `data_type` VARCHAR(10) NULL," + + " PRIMARY KEY (`uuid`, `preference_id`));"; + private static final String CREATE_TABLE_DATABASE_INFORMATION = + "CREATE TABLE IF NOT EXISTS `{prefix}database_information` (" + + " `key` VARCHAR(255) NOT NULL," + + " `value` VARCHAR(255) NOT NULL," + + " PRIMARY KEY (`key`));"; + + // Selection SQL + private static final String SELECT_PLAYER_QUEST_PROGRESS = + "SELECT quest_id, started, started_date, completed, completed_before, completion_date FROM `{prefix}quest_progress` WHERE uuid = ?;"; + private static final String SELECT_PLAYER_TASK_PROGRESS = + "SELECT quest_id, task_id, completed, progress, data_type FROM `{prefix}task_progress` WHERE uuid = ?;"; + private static final String SELECT_UUID_LIST = + "SELECT DISTINCT uuid FROM `{prefix}quest_progress`;"; + + // Insertion SQL + private static final String INSERT_PLAYER_QUEST_PROGRESS = + "INSERT INTO `{prefix}quest_progress` (uuid, quest_id, started, started_date, completed, completed_before, completion_date) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE started = ?, started_date = ?, completed = ?, completed_before = ?, completion_date = ?;"; + private static final String INSERT_PLAYER_TASK_PROGRESS = + "INSERT INTO `{prefix}task_progress` (uuid, quest_id, task_id, completed, progress, data_type) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE completed = ?, progress = ?, data_type = ?"; + + private static final Map ADDITIONAL_PROPERTIES = new HashMap<>() {{ + this.put("cachePrepStmts", true); + this.put("prepStmtCacheSize", 250); + this.put("prepStmtCacheSqlLimit", 2048); + this.put("useServerPrepStmts", true); + this.put("useLocalSessionState", true); + this.put("rewriteBatchedStatements", true); + this.put("cacheResultSetMetadata", true); + this.put("cacheServerConfiguration", true); + this.put("elideSetAutoCommits", true); + this.put("maintainTimeStats", false); + }}; + + private final BukkitQuestsPlugin plugin; + private final ConfigurationSection config; + + private HikariDataSource ds; + private Function prefixer; + private boolean validateQuests; + private boolean fault; + + public ModernMySQLStorageProvider(final @NotNull BukkitQuestsPlugin plugin, final @Nullable ConfigurationSection config) { + this.plugin = Objects.requireNonNull(plugin, "plugin cannot be null"); + this.config = Objects.requireNonNullElseGet(config, YamlConfiguration::new); + this.fault = true; + } + + @Override + public @NotNull String getName() { + return "mysql"; + } + + @Override + public void init() throws IOException { + // initialize hikari config and set pool name + final HikariConfig hikariConfig = new HikariConfig(); + hikariConfig.setPoolName("quests-hikari"); + + // set jdbc url + final String address = this.config.getString("network.address", "localhost:3306"); + final String database = this.config.getString("network.database", "minecraft"); + final String jdbcUrl = "jdbc:mysql://" + address + "/" + database; + hikariConfig.setJdbcUrl(jdbcUrl); + + // set username + final String username = this.config.getString("network.username", "root"); + hikariConfig.setUsername(username); + + // set password + final String password = this.config.getString("network.password"); + hikariConfig.setPassword(password); + + // set pool size related properties + final int minIdle = this.config.getInt("connection-pool-settings.minimum-idle", 8); + final int maxPoolSize = this.config.getInt("connection-pool-settings.maximum-pool-size", 8); + hikariConfig.setMinimumIdle(minIdle); + hikariConfig.setMaximumPoolSize(maxPoolSize); + + // set pool timeouts related properties + final long connectionTimeoutMs = this.config.getLong("connection-pool-settings.connection-timeout", 5000L); + final long idleTimeoutMs = this.config.getLong("connection-pool-settings.idle-timeout", 600000L); + final long keepaliveTimeMs = this.config.getLong("connection-pool-settings.keepalive-time", 0L); + final long maxLifetimeMs = this.config.getLong("connection-pool-settings.maximum-lifetime", 1800000L); + hikariConfig.setConnectionTimeout(connectionTimeoutMs); + hikariConfig.setIdleTimeout(idleTimeoutMs); + hikariConfig.setKeepaliveTime(keepaliveTimeMs); + hikariConfig.setMaxLifetime(maxLifetimeMs); + + // set additional datasource properties + for (final Map.Entry property : ADDITIONAL_PROPERTIES.entrySet()) { + hikariConfig.addDataSourceProperty(property.getKey(), property.getValue()); + } + + // Add additional custom data source properties + final ConfigurationSection propertiesSection = this.config.getConfigurationSection("connection-pool-settings.data-source-properties"); + if (propertiesSection != null) { + final Set properties = propertiesSection.getKeys(false); + + for (final String propertyName : properties) { + final Object propertyValue = propertiesSection.get(propertyName); + hikariConfig.addDataSourceProperty(propertyName, propertyValue); + } + } + + // initialize data source + this.ds = new HikariDataSource(hikariConfig); + + // set table prefixer + final String prefix = this.config.getString("table-prefix", "quests_"); + this.prefixer = s -> s.replace("{prefix}", prefix); + + // set whether quests ids should be validated + this.validateQuests = this.plugin.getConfig().getBoolean("options.verify-quest-exists-on-load", true); + + // create and upgrade default tables + try (final Connection conn = this.ds.getConnection()) { + try (final Statement stmt = conn.createStatement()) { + this.plugin.getQuestsLogger().debug("Creating default tables."); + + stmt.addBatch(this.prefixer.apply(CREATE_TABLE_QUEST_PROGRESS)); + stmt.addBatch(this.prefixer.apply(CREATE_TABLE_TASK_PROGRESS)); + stmt.addBatch(this.prefixer.apply(CREATE_TABLE_PLAYER_PREFERENCES)); + stmt.addBatch(this.prefixer.apply(CREATE_TABLE_DATABASE_INFORMATION)); + + stmt.executeBatch(); + } + + final DatabaseMigrator migrator = new DatabaseMigrator(this.plugin, this.prefixer, conn); + final int currentSchemaVersion = migrator.getCurrentSchemaVersion(); + + // upgrade the table only if current schema version is lower than the latest + if (currentSchemaVersion < DatabaseMigrator.LATEST_SCHEMA_VERSION) { + this.plugin.getLogger().info("Automatically upgrading database schema from version " + currentSchemaVersion + " to " + DatabaseMigrator.LATEST_SCHEMA_VERSION + "."); + migrator.upgrade(currentSchemaVersion); + } + } catch (final SQLException e) { + throw new IOException("Failed to create or upgrade default tables", e); + } + + this.fault = false; + } + + @Override + public void shutdown() { + if (this.ds != null) { + this.ds.close(); + } + } + + @Override + public @Nullable QPlayerData loadPlayerData(final @NotNull UUID uuid) { + Objects.requireNonNull(uuid, "uuid cannot be null"); + + if (this.fault) { + return null; + } + + final String uuidString = uuid.toString(); + final QuestProgressFile questProgressFile = new QuestProgressFile(this.plugin, uuid); + + try (final Connection conn = this.ds.getConnection()) { + this.plugin.getQuestsLogger().debug("Querying player data for " + uuidString + "."); + + final Map questProgressMap = new HashMap<>(); + + try (final PreparedStatement stmt = conn.prepareStatement(this.prefixer.apply(SELECT_PLAYER_QUEST_PROGRESS))) { + stmt.setString(1, uuidString); + + try (final ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + final String questId = rs.getString(1); + + if (this.validateQuests) { + final Quest quest = this.plugin.getQuestManager().getQuestById(questId); + + if (quest == null) { + continue; + } + } + + final boolean started = rs.getBoolean(2); + final long startedDate = rs.getLong(3); + final boolean completed = rs.getBoolean(4); + final boolean completedBefore = rs.getBoolean(5); + final long completionDate = rs.getLong(6); + + final QuestProgress questProgress = new QuestProgress(this.plugin, questId, uuid, started, startedDate, completed, completedBefore, completionDate); + questProgressMap.put(questId, questProgress); + } + } + } + + try (final PreparedStatement stmt = conn.prepareStatement(this.prefixer.apply(SELECT_PLAYER_TASK_PROGRESS))) { + stmt.setString(1, uuidString); + + try (final ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + final String questId = rs.getString(1); + + final QuestProgress questProgress = questProgressMap.get(questId); + if (questProgress == null) { + continue; + } + + final String taskId = rs.getString(2); + + if (this.validateQuests) { + final Quest quest = this.plugin.getQuestManager().getQuestById(questId); + if (quest == null) { + continue; + } + + final Task task = quest.getTaskById(taskId); + if (task == null) { + continue; + } + } + + final boolean completed = rs.getBoolean(3); + final String progressString = rs.getString(4); + final String dataTypeString = rs.getString(5); + + // maybe make an enum and use Enum#valueOf & then make a switch for enum instead? + // not sure about performance impact, probably just a small gain - need to benchmark it + final Object progress; + try { + progress = switch (dataTypeString) { + case null -> null; + case "int" -> Integer.parseInt(progressString); + case "float" -> Float.parseFloat(progressString); + case "long" -> Long.parseLong(progressString); + case "double" -> Double.parseDouble(progressString); + case "BigInteger" -> new BigInteger(progressString); + case "BigDecimal" -> new BigDecimal(progressString); + default -> throw new IllegalArgumentException("Unexpected data type: '" + dataTypeString + "'"); + }; + } catch (final NumberFormatException e) { + this.plugin.getLogger().log(Level.WARNING, "Cannot retrieve progress for task '" + taskId + + "' in quest '" + questId + "' for player " + uuidString + " since progress string '" + + progressString + "' is malformed!", e); + continue; + } catch (final IllegalArgumentException e) { + this.plugin.getLogger().log(Level.WARNING, "Cannot retrieve progress for task '" + taskId + + "' in quest '" + questId + "' for player " + uuidString + " since data type string '" + + dataTypeString + "' is unknown!", e); + continue; + } + + final TaskProgress taskProgress = new TaskProgress(questProgress, taskId, uuid, progress, completed); + questProgress.addTaskProgress(taskProgress); + } + } + } + + final Collection allQuestProgress = questProgressMap.values(); + + for (final QuestProgress questProgress : allQuestProgress) { + questProgressFile.addQuestProgress(questProgress); + } + } catch (final SQLException e) { + this.plugin.getLogger().log(Level.SEVERE, "Failed to load player data for " + uuidString + ".", e); + return null; + } + + return new QPlayerData(uuid, null, questProgressFile); // TODO player preferences + } + + @Override + public boolean savePlayerData(final @NotNull QPlayerData playerData) { + Objects.requireNonNull(playerData, "playerData cannot be null"); + + if (this.fault) { + return false; + } + + final UUID uuid = playerData.playerUUID(); + final String uuidString = uuid.toString(); // call it only once + + try (final Connection connection = this.ds.getConnection(); + final PreparedStatement questStmt = connection.prepareStatement(this.prefixer.apply(INSERT_PLAYER_QUEST_PROGRESS)); + final PreparedStatement taskStmt = connection.prepareStatement(this.prefixer.apply(INSERT_PLAYER_TASK_PROGRESS))) { + + this.plugin.getQuestsLogger().debug("Saving player data for " + uuidString + "."); + + final QuestProgressFile questProgressFile = playerData.questProgressFile(); + + for (final QuestProgress questProgress : questProgressFile.getAllQuestProgress()) { + if (!questProgress.isModified()) { + continue; + } + + final String questId = questProgress.getQuestId(); + + questStmt.setString(1, uuidString); + questStmt.setString(2, questId); + questStmt.setBoolean(3, questProgress.isStarted()); + questStmt.setLong(4, questProgress.getStartedDate()); + questStmt.setBoolean(5, questProgress.isCompleted()); + questStmt.setBoolean(6, questProgress.isCompletedBefore()); + questStmt.setLong(7, questProgress.getCompletionDate()); + questStmt.setBoolean(8, questProgress.isStarted()); + questStmt.setLong(9, questProgress.getStartedDate()); + questStmt.setBoolean(10, questProgress.isCompleted()); + questStmt.setBoolean(11, questProgress.isCompletedBefore()); + questStmt.setLong(12, questProgress.getCompletionDate()); + questStmt.addBatch(); + + for (final TaskProgress taskProgress : questProgress.getAllTaskProgress()) { + final String taskId = taskProgress.getTaskId(); + + final Object progress = taskProgress.getProgress(); + final String progressString; + final String dataTypeString; + + switch (progress) { + case null -> { + progressString = null; + dataTypeString = null; + } + case Integer i -> { + progressString = Integer.toString(i); + dataTypeString = "int"; + } + case Float f -> { + progressString = Float.toString(f); + dataTypeString = "float"; + } + case Long l -> { + progressString = Long.toString(l); + dataTypeString = "long"; + } + case Double d -> { + progressString = Double.toString(d); + dataTypeString = "double"; + } + case BigInteger bi -> { + progressString = bi.toString(); + dataTypeString = "BigInteger"; + } + case BigDecimal bd -> { + progressString = bd.toString(); + dataTypeString = "BigDecimal"; + } + default -> { + this.plugin.getLogger().warning("Cannot retrieve progress for task '" + taskId + + "' in quest '" + questId + "' for player " + uuidString + " since a valid encoder for '" + + progress.getClass().getName() + "' class has not been found!"); + continue; + } + } + + taskStmt.setString(1, uuidString); + taskStmt.setString(2, questId); + taskStmt.setString(3, taskId); + taskStmt.setBoolean(4, taskProgress.isCompleted()); + taskStmt.setString(5, progressString); + taskStmt.setString(6, dataTypeString); + taskStmt.setBoolean(7, taskProgress.isCompleted()); + taskStmt.setString(8, progressString); + taskStmt.setString(9, dataTypeString); + taskStmt.addBatch(); + } + } + + questStmt.executeBatch(); + taskStmt.executeBatch(); + + return true; + } catch (final SQLException e) { + this.plugin.getLogger().log(Level.SEVERE, "Failed to save player data for " + uuidString + ".", e); + return false; + } + } + + @Override + public @NotNull List loadAllPlayerData() { + if (this.fault) { + return Collections.emptyList(); + } + + final List uuids = new ArrayList<>(); + + try (final Connection conn = this.ds.getConnection(); + final PreparedStatement stmt = conn.prepareStatement(this.prefixer.apply(SELECT_UUID_LIST)); + final ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + // Get it by index to speed up it a little bit + final String uuidString = rs.getString(1); + + final UUID uuid; + try { + uuid = UUID.fromString(uuidString); + } catch (final IllegalArgumentException e) { + this.plugin.getLogger().log(Level.SEVERE, "Failed to parse player UUID: '" + uuidString + "'.", e); + continue; + } + + uuids.add(uuid); + } + } catch (final SQLException e) { + this.plugin.getLogger().log(Level.SEVERE, "Failed to load player UUIDs.", e); + return Collections.emptyList(); + } + + final List allPlayerData = new ArrayList<>(); + + for (final UUID uuid : uuids) { + final QPlayerData playerData = this.loadPlayerData(uuid); + + if (playerData != null) { + allPlayerData.add(playerData); + } + } + + return allPlayerData; + } + + @SuppressWarnings("RedundantIfStatement") // I hate it, but keep it just for readability + @Override + public boolean isSimilar(final @NotNull StorageProvider otherProvider) { + Objects.requireNonNull(otherProvider, "otherProvider cannot be null"); + + if (!(otherProvider instanceof final ModernMySQLStorageProvider mySQLProvider)) { + return false; + } + + final String address = this.config.getString("network.address", "localhost:3306"); + final String otherAddress = mySQLProvider.config.getString("network.address", "localhost:3306"); + + if (!address.equals(otherAddress)) { + return false; + } + + final String database = this.config.getString("network.database", "minecraft"); + final String otherDatabase = mySQLProvider.config.getString("network.database", "minecraft"); + + if (!database.equals(otherDatabase)) { + return false; + } + + return true; + } + + private record DatabaseMigrator(@NotNull BukkitQuestsPlugin plugin, @NotNull Function prefixer, @NotNull Connection conn) { + + private static final String GET_STARTED_DATE_COLUMN = + "SHOW COLUMNS from `{prefix}quest_progress` LIKE 'started_date';"; + private static final String SELECT_SCHEMA_VERSION = + "SELECT value FROM `{prefix}database_information` WHERE `key` LIKE 'schema_version';"; + private static final String UPDATE_DATABASE_INFORMATION = + "INSERT INTO `{prefix}database_information` (`key`, `value`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `value` = ?;"; + + private static final int LATEST_SCHEMA_VERSION = 2; + private static final Map MIGRATION_STATEMENTS = new HashMap<>() {{ + this.put(1, "ALTER TABLE `{prefix}quest_progress` ADD COLUMN `started_date` BIGINT NOT NULL AFTER `started`;"); + }}; + + private DatabaseMigrator(final @NotNull BukkitQuestsPlugin plugin, final @NotNull Function prefixer, final @NotNull Connection conn) { + this.plugin = Objects.requireNonNull(plugin, "plugin cannot be null"); + this.prefixer = Objects.requireNonNull(prefixer, "prefixer cannot be null"); + this.conn = Objects.requireNonNull(conn, "conn cannot be null"); + } + + public int getInitialSchemaVersion() throws SQLException { + this.plugin.getQuestsLogger().debug("Getting initial schema version for new database."); + + try (final Statement stmt = this.conn.createStatement(); + final ResultSet rs = stmt.executeQuery(this.prefixer.apply(GET_STARTED_DATE_COLUMN))) { + + if (rs.first()) { + return LATEST_SCHEMA_VERSION; + } else { + return 1; + } + } + } + + public int getCurrentSchemaVersion() throws SQLException { + this.plugin.getQuestsLogger().debug("Getting current schema version."); + + try (final Statement stmt = this.conn.createStatement(); + final ResultSet rs = stmt.executeQuery(this.prefixer.apply(SELECT_SCHEMA_VERSION))) { + + if (rs.first()) { + final int version = Integer.parseUnsignedInt(rs.getString(1)); + this.plugin.getQuestsLogger().debug("Current schema version: " + version + "."); + return version; + } + + final int version = this.getInitialSchemaVersion(); + this.updateSchemaVersion(version); + + return version; + } + } + + public void updateSchemaVersion(final int updatedSchemaVersion) throws SQLException { + this.plugin.getQuestsLogger().debug("Updating schema version to " + updatedSchemaVersion + "."); + + try (final PreparedStatement stmt = this.conn.prepareStatement(this.prefixer.apply(UPDATE_DATABASE_INFORMATION))) { + stmt.setString(1, "schema_version"); + stmt.setString(2, Integer.toString(updatedSchemaVersion)); + stmt.setString(3, Integer.toString(updatedSchemaVersion)); + + stmt.executeUpdate(); + } + } + + public void upgrade(final int initialSchemaVersion) throws SQLException { + this.plugin.getQuestsLogger().debug("Starting upgrade from version " + initialSchemaVersion + " to " + LATEST_SCHEMA_VERSION + "."); + + for (int i = initialSchemaVersion; i < LATEST_SCHEMA_VERSION; i++) { + final String statementString = this.prefixer.apply(MIGRATION_STATEMENTS.get(i)); + this.plugin.getQuestsLogger().debug("Running migration statement: " + statementString + "."); + + try (final Statement stmt = this.conn.createStatement()) { + stmt.execute(statementString); + } catch (final SQLException e) { + this.plugin.getLogger().severe("Failed to run migration statement (" + i + " -> " + (i + 1) + "): " + statementString + "."); + this.plugin.getLogger().severe("Quests will attempt to save current migration progress to prevent database corruption, but may not be able to do so."); + this.updateSchemaVersion(i); + + // we still want it to throw and prevent further plugin loading + throw e; + } + } + + this.updateSchemaVersion(LATEST_SCHEMA_VERSION); + } + } +} diff --git a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/ModernYAMLStorageProvider.java b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/ModernYAMLStorageProvider.java new file mode 100644 index 000000000..668b4b89f --- /dev/null +++ b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/ModernYAMLStorageProvider.java @@ -0,0 +1,284 @@ +package com.leonardobishop.quests.bukkit.storage; + +import com.leonardobishop.quests.bukkit.BukkitQuestsPlugin; +import com.leonardobishop.quests.common.player.QPlayerData; +import com.leonardobishop.quests.common.player.questprogressfile.QuestProgress; +import com.leonardobishop.quests.common.player.questprogressfile.QuestProgressFile; +import com.leonardobishop.quests.common.player.questprogressfile.TaskProgress; +import com.leonardobishop.quests.common.quest.Quest; +import com.leonardobishop.quests.common.quest.Task; +import com.leonardobishop.quests.common.storage.StorageProvider; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; +import java.util.logging.Level; + +public final class ModernYAMLStorageProvider implements StorageProvider { + + private final BukkitQuestsPlugin plugin; + private final File dataDirectory; + private final Map lockMap; + + private boolean validateQuests; + + public ModernYAMLStorageProvider(final @NotNull BukkitQuestsPlugin plugin) { + this.plugin = Objects.requireNonNull(plugin, "plugin cannot be null"); + this.dataDirectory = new File(plugin.getDataFolder(), "playerdata"); + this.lockMap = new ConcurrentHashMap<>(); + } + + @Override + public @NotNull String getName() { + return "yaml"; + } + + @Override + public void init() { + //noinspection ResultOfMethodCallIgnored + this.dataDirectory.mkdirs(); + + // not really useful now, but maybe in the future it will be reloadable + this.validateQuests = this.plugin.getConfig().getBoolean("options.verify-quest-exists-on-load", true); + } + + @Override + public void shutdown() { + // no implementation needed + } + + @Override + public @Nullable QPlayerData loadPlayerData(final @NotNull UUID uuid) { + Objects.requireNonNull(uuid, "uuid cannot be null"); + + final String uuidString = uuid.toString(); + final QuestProgressFile questProgressFile = new QuestProgressFile(this.plugin, uuid); + final File dataFile = new File(this.dataDirectory, uuidString + ".yml"); + + final ReentrantLock lock = this.lock(uuid); + + try { + if (dataFile.isFile()) { + final YamlConfiguration data = new YamlConfiguration(); + data.load(dataFile); + + this.plugin.getQuestsLogger().debug("Player " + uuidString + " has a valid quest progress file."); + + final ConfigurationSection questProgressSection = data.getConfigurationSection("quest-progress"); + + if (questProgressSection != null) { + final Set questIds = questProgressSection.getKeys(false); + + for (final String questId : questIds) { + final Quest quest; + + if (this.validateQuests) { + quest = this.plugin.getQuestManager().getQuestById(questId); + + if (quest == null) { + continue; + } + } else { + quest = null; + } + + final ConfigurationSection questSection = questProgressSection.getConfigurationSection(questId); + + //noinspection DataFlowIssue + final boolean qStarted = questSection.getBoolean("started", false); + final long qStartedDate = questSection.getLong("started-date", 0L); + final boolean qCompleted = questSection.getBoolean("completed", false); + final boolean qCompletedBefore = questSection.getBoolean("completed-before", false); + final long qCompletionDate = questSection.getLong("completion-date", 0L); + + final QuestProgress questProgress = new QuestProgress(this.plugin, questId, uuid, qStarted, qStartedDate, qCompleted, qCompletedBefore, qCompletionDate); + + final ConfigurationSection taskProgressSection = questSection.getConfigurationSection("task-progress"); + + if (taskProgressSection != null) { + final Set taskIds = taskProgressSection.getKeys(false); + + for (final String taskId : taskIds) { + // quest is not null only if this.validateQuests is true + if (quest != null) { + final Task task = quest.getTaskById(taskId); + + if (task == null) { + continue; + } + } + + final ConfigurationSection taskSection = taskProgressSection.getConfigurationSection(taskId); + + //noinspection DataFlowIssue + final boolean tCompleted = taskSection.getBoolean("completed", false); + final Object tProgress = taskSection.get("progress", null); + + final TaskProgress taskProgress = new TaskProgress(questProgress, taskId, uuid, tProgress, tCompleted); + questProgress.addTaskProgress(taskProgress); + } + } + + questProgressFile.addQuestProgress(questProgress); + } + } + } else { + this.plugin.getQuestsLogger().debug("Player " + uuidString + " does not have a quest progress file."); + } + + return new QPlayerData(uuid, null, questProgressFile); // TODO player preferences + } catch (final FileNotFoundException e) { + this.plugin.getLogger().log(Level.SEVERE, "Failed to find player data file for " + uuidString + ".", e); + } catch (final IOException e) { + this.plugin.getLogger().log(Level.SEVERE, "Failed to read player data file for " + uuidString + ".", e); + } catch (final InvalidConfigurationException e) { + this.plugin.getLogger().log(Level.SEVERE, "Failed to parse player data file for " + uuidString + ".", e); + } finally { + lock.unlock(); + } + + return null; + } + + @Override + public boolean savePlayerData(final @NotNull QPlayerData playerData) { + Objects.requireNonNull(playerData, "playerData cannot be null"); + + final UUID uuid = playerData.playerUUID(); + final String uuidString = uuid.toString(); + final QuestProgressFile questProgressFile = playerData.questProgressFile(); + + final ReentrantLock lock = this.lock(uuid); + + try { + final File dataFile = new File(this.dataDirectory, uuidString + ".yml"); + final YamlConfiguration data = new YamlConfiguration(); + + if (dataFile.isFile()) { + data.load(dataFile); + this.plugin.getQuestsLogger().debug("Player " + uuidString + " has a valid quest progress file."); + } else { + this.plugin.getQuestsLogger().debug("Player " + uuidString + " does not have a quest progress file."); + } + + for (final QuestProgress questProgress : questProgressFile.getAllQuestProgress()) { + if (!questProgress.isModified()) { + continue; + } + + final String questId = questProgress.getQuestId(); + + data.set("quest-progress." + questId + ".started", questProgress.isStarted()); + data.set("quest-progress." + questId + ".started-date", questProgress.getStartedDate()); + data.set("quest-progress." + questId + ".completed", questProgress.isCompleted()); + data.set("quest-progress." + questId + ".completed-before", questProgress.isCompletedBefore()); + data.set("quest-progress." + questId + ".completion-date", questProgress.getCompletionDate()); + + for (final TaskProgress taskProgress : questProgress.getAllTaskProgress()) { + final String taskId = taskProgress.getTaskId(); + + data.set("quest-progress." + questId + ".task-progress." + taskId + ".completed", taskProgress.isCompleted()); + data.set("quest-progress." + questId + ".task-progress." + taskId + ".progress", taskProgress.getProgress()); + } + } + + this.plugin.getQuestsLogger().debug("Saving player data file for " + uuidString + " to disk."); + + try { + data.save(dataFile); + return true; + } catch (final IOException e) { + this.plugin.getLogger().log(Level.SEVERE, "Failed to write player data file for " + uuidString + ".", e); + } + } catch (final FileNotFoundException e) { + this.plugin.getLogger().log(Level.SEVERE, "Failed to find player data file for " + uuidString + ".", e); + } catch (final IOException e) { + this.plugin.getLogger().log(Level.SEVERE, "Failed to read player data file for " + uuidString + ".", e); + } catch (final InvalidConfigurationException e) { + this.plugin.getLogger().log(Level.SEVERE, "Failed to parse player data file for " + uuidString + ".", e); + } finally { + lock.unlock(); + } + + return false; + } + + @Override + public @NotNull List loadAllPlayerData() { + final List allPlayerData = new ArrayList<>(); + final PlayerDataVisitor playerDataVisitor = new PlayerDataVisitor(this, allPlayerData); + + try { + Files.walkFileTree(this.dataDirectory.toPath(), playerDataVisitor); + } catch (final IOException e) { + this.plugin.getLogger().log(Level.SEVERE, "Failed to walk the player data file tree", e); + } + + return allPlayerData; + } + + @Override + public boolean isSimilar(final @NotNull StorageProvider otherProvider) { + return otherProvider instanceof ModernYAMLStorageProvider; + } + + private @NotNull ReentrantLock lock(final @NotNull UUID uuid) { + final ReentrantLock lock = this.lockMap.computeIfAbsent(uuid, k -> new ReentrantLock()); + lock.lock(); + return lock; + } + + private static class PlayerDataVisitor extends SimpleFileVisitor { + + private static final String FILE_EXTENSION = ".yml"; + + private final ModernYAMLStorageProvider provider; + private final List allPlayerData; + + public PlayerDataVisitor(final @NotNull ModernYAMLStorageProvider provider, final @NotNull List allPlayerData) { + this.provider = provider; + this.allPlayerData = allPlayerData; + } + + @Override + public @NotNull FileVisitResult visitFile(final @NotNull Path path, final @NotNull BasicFileAttributes attributes) { + final String fileName = path.toFile().getName(); + final String uuidString = fileName.substring(0, fileName.length() - FILE_EXTENSION.length()); + + if (fileName.endsWith(FILE_EXTENSION)) { + final UUID uuid; + try { + uuid = UUID.fromString(uuidString); + } catch (final IllegalArgumentException e) { + this.provider.plugin.getLogger().log(Level.SEVERE, "Failed to parse player UUID: '" + uuidString + "'.", e); + return FileVisitResult.CONTINUE; + } + + final QPlayerData playerData = this.provider.loadPlayerData(uuid); + if (playerData != null) { + this.allPlayerData.add(playerData); + } + } + + return FileVisitResult.CONTINUE; + } + } +} diff --git a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/MySqlStorageProvider.java b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/MySqlStorageProvider.java deleted file mode 100644 index f8eaefbab..000000000 --- a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/MySqlStorageProvider.java +++ /dev/null @@ -1,471 +0,0 @@ -package com.leonardobishop.quests.bukkit.storage; - -import com.leonardobishop.quests.bukkit.BukkitQuestsPlugin; -import com.leonardobishop.quests.common.player.questprogressfile.QuestProgress; -import com.leonardobishop.quests.common.player.questprogressfile.QuestProgressFile; -import com.leonardobishop.quests.common.player.questprogressfile.TaskProgress; -import com.leonardobishop.quests.common.quest.Quest; -import com.leonardobishop.quests.common.storage.StorageProvider; -import com.zaxxer.hikari.HikariConfig; -import com.zaxxer.hikari.HikariDataSource; -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.file.YamlConfiguration; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.math.BigDecimal; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.sql.Statement; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.UUID; -import java.util.function.Function; - -public class MySqlStorageProvider implements StorageProvider { - - private static final String CREATE_TABLE_QUEST_PROGRESS = - "CREATE TABLE IF NOT EXISTS `{prefix}quest_progress` (" + - " `uuid` VARCHAR(36) NOT NULL," + - " `quest_id` VARCHAR(50) NOT NULL," + - " `started` BOOL NOT NULL," + - " `started_date` BIGINT NOT NULL," + - " `completed` BOOL NOT NULL," + - " `completed_before` BOOL NOT NULL," + - " `completion_date` BIGINT NOT NULL," + - " PRIMARY KEY (`uuid`, `quest_id`));"; - private static final String CREATE_TABLE_TASK_PROGRESS = - "CREATE TABLE IF NOT EXISTS `{prefix}task_progress` (" + - " `uuid` VARCHAR(36) NOT NULL," + - " `quest_id` VARCHAR(50) NOT NULL," + - " `task_id` VARCHAR(50) NOT NULL," + - " `completed` BOOL NOT NULL," + - " `progress` VARCHAR(64) NULL," + - " `data_type` VARCHAR(10) NULL," + - " PRIMARY KEY (`uuid`, `quest_id`, `task_id`));"; - private static final String CREATE_TABLE_DATABASE_INFORMATION = - "CREATE TABLE IF NOT EXISTS `{prefix}database_information` (" + - " `key` VARCHAR(255) NOT NULL," + - " `value` VARCHAR(255) NOT NULL," + - " PRIMARY KEY (`key`));"; - private static final String SELECT_PLAYER_QUEST_PROGRESS = - "SELECT quest_id, started, started_date, completed, completed_before, completion_date FROM `{prefix}quest_progress` WHERE uuid=?;"; - private static final String SELECT_PLAYER_TASK_PROGRESS = - "SELECT quest_id, task_id, completed, progress, data_type FROM `{prefix}task_progress` WHERE uuid=?;"; - private static final String SELECT_UUID_LIST = - "SELECT DISTINCT uuid FROM `{prefix}quest_progress`;"; - private static final String SELECT_KNOWN_PLAYER_QUEST_PROGRESS = - "SELECT quest_id FROM `{prefix}quest_progress` WHERE uuid=?;"; - private static final String SELECT_KNOWN_PLAYER_TASK_PROGRESS = - "SELECT quest_id, task_id FROM `{prefix}task_progress` WHERE uuid=?;"; - private static final String WRITE_PLAYER_QUEST_PROGRESS = - "INSERT INTO `{prefix}quest_progress` (uuid, quest_id, started, started_date, completed, completed_before, completion_date) VALUES (?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE started=?, started_date=?, completed=?, completed_before=?, completion_date=?"; - private static final String WRITE_PLAYER_TASK_PROGRESS = - "INSERT INTO `{prefix}task_progress` (uuid, quest_id, task_id, completed, progress, data_type) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE completed=?, progress=?, data_type=?"; - - private final ConfigurationSection configuration; - private final BukkitQuestsPlugin plugin; - private HikariDataSource hikari; - private String prefix; - private Function statementProcessor; - private boolean fault; - - public MySqlStorageProvider(BukkitQuestsPlugin plugin, ConfigurationSection configuration) { - this.plugin = plugin; - if (configuration == null) { - configuration = new YamlConfiguration(); - } - this.configuration = configuration; - this.fault = true; - } - - @Override - public String getName() { - return "mysql"; - } - - @Override - public void init() { - String address = configuration.getString("network.address", "localhost:3306"); - String database = configuration.getString("network.database", "minecraft"); - String url = "jdbc:mysql://" + address + "/" + database; - - HikariConfig config = new HikariConfig(); - config.setPoolName("quests-hikari"); - - config.setUsername(configuration.getString("network.username", "root")); - config.setPassword(configuration.getString("network.password", "")); - config.setJdbcUrl(url); - config.setMaximumPoolSize(configuration.getInt("connection-pool-settings.maximum-pool-size", 8)); - config.setMinimumIdle(configuration.getInt("connection-pool-settings.minimum-idle", 8)); - config.setMaxLifetime(configuration.getInt("connection-pool-settings.maximum-lifetime", 1800000)); - config.setConnectionTimeout(configuration.getInt("connection-pool-settings.connection-timeout", 5000)); - - config.addDataSourceProperty("cachePrepStmts", true); - config.addDataSourceProperty("prepStmtCacheSize", 250); - config.addDataSourceProperty("prepStmtCacheSqlLimit", 2048); - config.addDataSourceProperty("useServerPrepStmts", true); - config.addDataSourceProperty("useLocalSessionState", true); - config.addDataSourceProperty("rewriteBatchedStatements", true); - config.addDataSourceProperty("cacheResultSetMetadata", true); - config.addDataSourceProperty("cacheServerConfiguration", true); - config.addDataSourceProperty("elideSetAutoCommits", true); - config.addDataSourceProperty("maintainTimeStats", false); - - if (configuration.isConfigurationSection("connection-pool-settings.data-source-properties")) { - for (String property : configuration.getConfigurationSection("connection-pool-settings.data-source-properties").getKeys(false)) { - config.addDataSourceProperty(property, configuration.get("connection-pool-settings.data-source-properties." + property)); - } - } - - this.hikari = new HikariDataSource(config); - this.prefix = configuration.getString("database-settings.table-prefix", "quests_"); - this.statementProcessor = s -> s.replace("{prefix}", prefix); - try (Connection connection = hikari.getConnection()) { - try (Statement s = connection.createStatement()) { - plugin.getQuestsLogger().debug("Creating default tables"); - s.addBatch(this.statementProcessor.apply(CREATE_TABLE_QUEST_PROGRESS)); - s.addBatch(this.statementProcessor.apply(CREATE_TABLE_TASK_PROGRESS)); - s.addBatch(this.statementProcessor.apply(CREATE_TABLE_DATABASE_INFORMATION)); - - s.executeBatch(); - } - DatabaseMigrator migrator = new DatabaseMigrator(connection); - - int currentVersion = migrator.getCurrentSchemaVersion(); - if (currentVersion < DatabaseMigrator.CURRENT_SCHEMA_VERSION) { - plugin.getQuestsLogger().info("Automatically upgrading database schema from version " + currentVersion + " to " + DatabaseMigrator.CURRENT_SCHEMA_VERSION); - migrator.upgrade(currentVersion); - } - } catch (SQLException e) { - throw new RuntimeException(e); - } - this.fault = false; - } - - @Override - public void shutdown() { - if (hikari != null) hikari.close(); - } - - @Override - @Nullable - public QuestProgressFile loadProgressFile(@NotNull UUID uuid) { - Objects.requireNonNull(uuid, "uuid cannot be null"); - - if (fault) return null; - Map presentQuests = new HashMap<>(plugin.getQuestManager().getQuests()); - boolean validateQuests = plugin.getQuestsConfig().getBoolean("options.verify-quest-exists-on-load", true); - - QuestProgressFile questProgressFile = new QuestProgressFile(uuid, plugin); - try (Connection connection = hikari.getConnection()) { - plugin.getQuestsLogger().debug("Querying player " + uuid); - Map questProgressMap = new HashMap<>(); - try (PreparedStatement ps = connection.prepareStatement(this.statementProcessor.apply(SELECT_PLAYER_QUEST_PROGRESS))) { - ps.setString(1, uuid.toString()); - - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - String questId = rs.getString(1); - boolean started = rs.getBoolean(2); - long startedDate = rs.getLong(3); - boolean completed = rs.getBoolean(4); - boolean completedBefore = rs.getBoolean(5); - long completionDate = rs.getLong(6); - - if (validateQuests && !presentQuests.containsKey(questId)) continue; - QuestProgress questProgress = new QuestProgress(plugin, questId, completed, completedBefore, completionDate, uuid, started, startedDate); - questProgressMap.put(questId, questProgress); - } - } - } - try (PreparedStatement ps = connection.prepareStatement(this.statementProcessor.apply(SELECT_PLAYER_TASK_PROGRESS))) { - ps.setString(1, uuid.toString()); - - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - String questId = rs.getString(1); - String taskId = rs.getString(2); - boolean completed = rs.getBoolean(3); - String encodedProgress = rs.getString(4); - String type = rs.getString(5); - Object progress; - try { - if (type == null) { - progress = null; - } else if (type.equals("double")) { - progress = Double.valueOf(encodedProgress); - } else if (type.equals("float")) { - progress = Float.valueOf(encodedProgress); - } else if (type.equals("int")) { - progress = Integer.valueOf(encodedProgress); - } else if (type.equals("BigDecimal")) { - progress = new BigDecimal(encodedProgress); - } else { - throw new RuntimeException("unknown data type '" + type + "'"); - } - } catch (NumberFormatException ex) { - plugin.getQuestsLogger().warning("Cannot retrieve progress for task '" - + taskId + "' in quest '" + questId + "' for player " + uuid - + " since data is malformed!"); - continue; - } catch (RuntimeException ex) { - if (ex.getMessage().startsWith("unknown data type ")) { - plugin.getQuestsLogger().warning("Cannot retrieve progress for task '" - + taskId + "' in quest '" + questId + "' for player " + uuid - + ": " + ex.getMessage()); - continue; - } else { - throw ex; - } - } - - QuestProgress linkedQuestProgress = questProgressMap.get(questId); - if (linkedQuestProgress == null) continue; - if (validateQuests) { - if (!presentQuests.containsKey(questId)) continue; - if (presentQuests.get(questId).getTaskById(taskId) == null) continue; - } - TaskProgress questProgress = new TaskProgress(linkedQuestProgress, taskId, progress, uuid, completed); - linkedQuestProgress.addTaskProgress(questProgress); - } - } - } - for (QuestProgress questProgress : questProgressMap.values()) { - questProgressFile.addQuestProgress(questProgress); - } - } catch (SQLException e) { - e.printStackTrace(); - return null; - } - return questProgressFile; - } - - @Override - public boolean saveProgressFile(@NotNull UUID uuid, @NotNull QuestProgressFile questProgressFile) { - Objects.requireNonNull(uuid, "uuid cannot be null"); - Objects.requireNonNull(questProgressFile, "questProgressFile cannot be null"); - - if (fault) return false; - try (Connection connection = hikari.getConnection()) { - try (PreparedStatement writeQuestProgress = connection.prepareStatement(this.statementProcessor.apply(WRITE_PLAYER_QUEST_PROGRESS)); - PreparedStatement writeTaskProgress = connection.prepareStatement(this.statementProcessor.apply(WRITE_PLAYER_TASK_PROGRESS))) { - - List questProgressValues = new ArrayList<>(questProgressFile.getAllQuestProgress()); - for (QuestProgress questProgress : questProgressValues) { - if (!questProgress.isModified()) continue; - - String questId = questProgress.getQuestId(); - writeQuestProgress.setString(1, uuid.toString()); - writeQuestProgress.setString(2, questProgress.getQuestId()); - writeQuestProgress.setBoolean(3, questProgress.isStarted()); - writeQuestProgress.setLong(4, questProgress.getStartedDate()); - writeQuestProgress.setBoolean(5, questProgress.isCompleted()); - writeQuestProgress.setBoolean(6, questProgress.isCompletedBefore()); - writeQuestProgress.setLong(7, questProgress.getCompletionDate()); - writeQuestProgress.setBoolean(8, questProgress.isStarted()); - writeQuestProgress.setLong(9, questProgress.getStartedDate()); - writeQuestProgress.setBoolean(10, questProgress.isCompleted()); - writeQuestProgress.setBoolean(11, questProgress.isCompletedBefore()); - writeQuestProgress.setLong(12, questProgress.getCompletionDate()); - writeQuestProgress.addBatch(); - - for (TaskProgress taskProgress : questProgress.getTaskProgress()) { - String taskId = taskProgress.getTaskId(); - - String encodedProgress; - Object progress = taskProgress.getProgress(); - String type; - if (progress == null) { - type = null; - encodedProgress = null; - } else if (progress instanceof Double) { - type = "double"; - encodedProgress = String.valueOf(progress); - } else if (progress instanceof Integer) { - type = "int"; - encodedProgress = String.valueOf(progress); - } else if (progress instanceof Float) { - type = "float"; - encodedProgress = String.valueOf(progress); - } else if (progress instanceof BigDecimal) { - type = "BigDecimal"; - encodedProgress = String.valueOf(progress); - } else { - plugin.getQuestsLogger().warning("Cannot store progress for task '" - + taskId + "' in quest '" + questId + "' for player " + uuid - + " since type " + progress.getClass().getName() + " cannot be encoded!"); - continue; - } - writeTaskProgress.setString(1, uuid.toString()); - writeTaskProgress.setString(2, questId); - writeTaskProgress.setString(3, taskProgress.getTaskId()); - writeTaskProgress.setBoolean(4, taskProgress.isCompleted()); - writeTaskProgress.setString(5, encodedProgress); - writeTaskProgress.setString(6, type); - writeTaskProgress.setBoolean(7, taskProgress.isCompleted()); - writeTaskProgress.setString(8, encodedProgress); - writeTaskProgress.setString(9, type); - writeTaskProgress.addBatch(); - } - } - - writeQuestProgress.executeBatch(); - writeTaskProgress.executeBatch(); - } - return true; - } catch (SQLException e) { - e.printStackTrace(); - return false; - } - } - - @Override - public @NotNull List loadAllProgressFiles() { - if (fault) return Collections.emptyList(); - - Set uuids = new HashSet<>(); - - try (Connection connection = hikari.getConnection()) { - try (PreparedStatement ps = connection.prepareStatement(this.statementProcessor.apply(SELECT_UUID_LIST))) { - try (ResultSet rs = ps.executeQuery()) { - while (rs.next()) { - String uuidString = rs.getString(1); - try { - UUID uuid = UUID.fromString(uuidString); - uuids.add(uuid); - } catch (IllegalArgumentException ignored) { } - } - } - } - } catch (SQLException e) { - e.printStackTrace(); - return Collections.emptyList(); - } - - List files = new ArrayList<>(); - for (UUID uuid : uuids) { - QuestProgressFile file = loadProgressFile(uuid); - if (file != null) { - files.add(file); - } - } - - return files; - } - - @Override - public void saveAllProgressFiles(List files) { - if (fault) return; - - for (QuestProgressFile file : files) { - saveProgressFile(file.getPlayerUUID(), file); - } - } - - @Override - public boolean isSimilar(StorageProvider provider) { - if (!(provider instanceof MySqlStorageProvider)) { - return false; - } - - MySqlStorageProvider other = (MySqlStorageProvider) provider; - - String address = configuration.getString("network.address", "localhost:3306"); - String database = configuration.getString("network.database", "minecraft"); - - String otherAddress = other.configuration.getString("network.address", "localhost:3306"); - String otherDatabase = other.configuration.getString("network.database", "minecraft"); - - return address.equalsIgnoreCase(otherAddress) && database.equalsIgnoreCase(otherDatabase); - } - - private class DatabaseMigrator { - private static final String GET_STARTED_DATE_COLUMN = - "SHOW COLUMNS from `{prefix}quest_progress` LIKE 'started_date';"; - private static final String SELECT_SCHEMA_VERSION = - "SELECT value FROM `{prefix}database_information` WHERE `key`='schema_version';"; - private static final String UPDATE_DATABASE_INFORMATION = - "INSERT INTO `{prefix}database_information` (`key`, `value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=?;"; - private static final int CURRENT_SCHEMA_VERSION = 2; - - private final Map migrationStatements = new HashMap<>(); - - private final Connection connection; - - public DatabaseMigrator(Connection connection) { - this.connection = connection; - - this.migrationStatements.put(1, - "ALTER TABLE `{prefix}quest_progress` ADD COLUMN `started_date` BIGINT NOT NULL AFTER `started`;"); - } - - public int getInitialSchemaVersion() { - try (Statement statement = connection.createStatement()) { - plugin.getQuestsLogger().debug("Getting initial schema version for new database"); - ResultSet rs = statement.executeQuery(statementProcessor.apply(GET_STARTED_DATE_COLUMN)); - boolean hasStartedDateColumn = rs.next(); - - return hasStartedDateColumn ? CURRENT_SCHEMA_VERSION : 1; - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - - public int getCurrentSchemaVersion() { - try (Statement statement = connection.createStatement()) { - plugin.getQuestsLogger().debug("Getting current schema version"); - ResultSet rs = statement.executeQuery(statementProcessor.apply(SELECT_SCHEMA_VERSION)); - if (rs.next()) { - int version = Integer.parseInt(rs.getString(1)); - plugin.getQuestsLogger().debug("Current schema version: " + version); - return version; - } else { - int initialVersion = getInitialSchemaVersion(); - updateSchemaVersion(initialVersion); - return initialVersion; - } - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - - public void upgrade(int initialSchemaVersion) { - plugin.getQuestsLogger().debug("Starting upgrade from version " + initialSchemaVersion + " to " + CURRENT_SCHEMA_VERSION); - for (int i = initialSchemaVersion; i < CURRENT_SCHEMA_VERSION; i++) { - String statement = statementProcessor.apply(migrationStatements.get(i)); - plugin.getQuestsLogger().debug("Running migration statement: " + statement); - try (Statement stmt = connection.createStatement()) { - stmt.execute(statementProcessor.apply(statement)); - } catch (SQLException e) { - plugin.getQuestsLogger().severe("Failed to run migration statement (" + i + " -> " + (i + 1) + "): " + statement); - plugin.getQuestsLogger().severe("Quests will attempt to save current migration progress to prevent database corruption, but may not be able to do so"); - updateSchemaVersion(i); - throw new RuntimeException(e); - } - } - updateSchemaVersion(CURRENT_SCHEMA_VERSION); - } - - public void updateSchemaVersion(int version) { - plugin.getQuestsLogger().debug("Updating schema version to " + version); - try (PreparedStatement stmt = connection.prepareStatement(statementProcessor.apply(UPDATE_DATABASE_INFORMATION))) { - stmt.setString(1, "schema_version"); - stmt.setString(2, String.valueOf(version)); - stmt.setString(3, String.valueOf(version)); - - stmt.execute(); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - } -} diff --git a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/YamlStorageProvider.java b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/YamlStorageProvider.java deleted file mode 100644 index e74212af1..000000000 --- a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/storage/YamlStorageProvider.java +++ /dev/null @@ -1,212 +0,0 @@ -package com.leonardobishop.quests.bukkit.storage; - -import com.leonardobishop.quests.bukkit.BukkitQuestsPlugin; -import com.leonardobishop.quests.common.player.questprogressfile.QuestProgress; -import com.leonardobishop.quests.common.player.questprogressfile.QuestProgressFile; -import com.leonardobishop.quests.common.player.questprogressfile.TaskProgress; -import com.leonardobishop.quests.common.quest.Quest; -import com.leonardobishop.quests.common.storage.StorageProvider; -import org.bukkit.configuration.file.YamlConfiguration; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.io.File; -import java.io.IOException; -import java.nio.file.FileVisitResult; -import java.nio.file.FileVisitor; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.SimpleFileVisitor; -import java.nio.file.attribute.BasicFileAttributes; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; - -public class YamlStorageProvider implements StorageProvider { - - private final Map locks = new ConcurrentHashMap<>(); - private final BukkitQuestsPlugin plugin; - - public YamlStorageProvider(BukkitQuestsPlugin plugin) { - this.plugin = plugin; - } - - private ReentrantLock lock(UUID uuid) { - locks.putIfAbsent(uuid, new ReentrantLock()); - ReentrantLock lock = locks.get(uuid); - lock.lock(); - return lock; - } - - @Override - public String getName() { - return "yaml"; - } - - @Override - public void init() { - File directory = new File(plugin.getDataFolder() + File.separator + "playerdata"); - directory.mkdirs(); - } - - @Override - public void shutdown() { - // no impl - } - - public @Nullable QuestProgressFile loadProgressFile(@NotNull UUID uuid) { - Objects.requireNonNull(uuid, "uuid cannot be null"); - - ReentrantLock lock = lock(uuid); - Map presentQuests = new HashMap<>(plugin.getQuestManager().getQuests()); - boolean validateQuests = plugin.getQuestsConfig().getBoolean("options.verify-quest-exists-on-load", true); - - QuestProgressFile questProgressFile = new QuestProgressFile(uuid, plugin); - try { - File directory = new File(plugin.getDataFolder() + File.separator + "playerdata"); - if (directory.exists() && directory.isDirectory()) { - File file = new File(plugin.getDataFolder() + File.separator + "playerdata" + File.separator + uuid.toString() + ".yml"); - if (file.exists()) { - YamlConfiguration data = YamlConfiguration.loadConfiguration(file); - plugin.getQuestsLogger().debug("Player " + uuid + " has a valid quest progress file."); - if (data.isConfigurationSection("quest-progress")) { //Same job as "isSet" + it checks if is CfgSection - for (String id : data.getConfigurationSection("quest-progress").getKeys(false)) { - boolean started = data.getBoolean("quest-progress." + id + ".started"); - long startedDate = data.getLong("quest-progress." + id + ".started-date"); - boolean completed = data.getBoolean("quest-progress." + id + ".completed"); - boolean completedBefore = data.getBoolean("quest-progress." + id + ".completed-before"); - long completionDate = data.getLong("quest-progress." + id + ".completion-date"); - - if (validateQuests && !presentQuests.containsKey(id)) continue; - - QuestProgress questProgress = new QuestProgress(plugin, id, completed, completedBefore, completionDate, uuid, started, startedDate, true); - - if (data.isConfigurationSection("quest-progress." + id + ".task-progress")) { - for (String taskid : data.getConfigurationSection("quest-progress." + id + ".task-progress").getKeys(false)) { - boolean taskCompleted = data.getBoolean("quest-progress." + id + ".task-progress." + taskid + ".completed"); - Object taskProgression = data.get("quest-progress." + id + ".task-progress." + taskid + ".progress"); - - if (validateQuests && presentQuests.get(id).getTaskById(taskid) == null) continue; - - TaskProgress taskProgress = new TaskProgress(questProgress, taskid, taskProgression, uuid, taskCompleted, false); - questProgress.addTaskProgress(taskProgress); - } - } - - questProgressFile.addQuestProgress(questProgress); - } - } - } else { - plugin.getQuestsLogger().debug("Player " + uuid + " does not have a quest progress file."); - } - } - } catch (Exception ex) { - ex.printStackTrace(); - return null; - } finally { - lock.unlock(); - } - - return questProgressFile; - } - - public boolean saveProgressFile(@NotNull UUID uuid, @NotNull QuestProgressFile questProgressFile) { - Objects.requireNonNull(uuid, "uuid cannot be null"); - Objects.requireNonNull(questProgressFile, "questProgressFile cannot be null"); - - ReentrantLock lock = lock(uuid); - try { - List questProgressValues = new ArrayList<>(questProgressFile.getAllQuestProgress()); - File directory = new File(plugin.getDataFolder() + File.separator + "playerdata"); - if (!directory.exists() && !directory.isDirectory()) { - directory.mkdirs(); - } - - File file = new File(plugin.getDataFolder() + File.separator + "playerdata" + File.separator + uuid.toString() + ".yml"); - if (!file.exists()) { - try { - file.createNewFile(); - } catch (IOException e) { - e.printStackTrace(); - } - } - - YamlConfiguration data = YamlConfiguration.loadConfiguration(file); - for (QuestProgress questProgress : questProgressValues) { - if (!questProgress.isModified()) continue; - data.set("quest-progress." + questProgress.getQuestId() + ".started", questProgress.isStarted()); - data.set("quest-progress." + questProgress.getQuestId() + ".started-date", questProgress.getStartedDate()); - data.set("quest-progress." + questProgress.getQuestId() + ".completed", questProgress.isCompleted()); - data.set("quest-progress." + questProgress.getQuestId() + ".completed-before", questProgress.isCompletedBefore()); - data.set("quest-progress." + questProgress.getQuestId() + ".completion-date", questProgress.getCompletionDate()); - for (TaskProgress taskProgress : questProgress.getTaskProgress()) { - data.set("quest-progress." + questProgress.getQuestId() + ".task-progress." + taskProgress.getTaskId() + ".completed", taskProgress - .isCompleted()); - data.set("quest-progress." + questProgress.getQuestId() + ".task-progress." + taskProgress.getTaskId() + ".progress", taskProgress - .getProgress()); - } - } - - plugin.getQuestsLogger().debug("Writing player " + uuid + " to disk."); - try { - data.save(file); - return true; - } catch (IOException e) { - e.printStackTrace(); - return false; - } - } finally { - lock.unlock(); - } - } - - public @NotNull List loadAllProgressFiles() { - List files = new ArrayList<>(); - - File directory = new File(plugin.getDataFolder() + File.separator + "playerdata"); - FileVisitor fileVisitor = new SimpleFileVisitor() { - @Override - public FileVisitResult visitFile(Path path, BasicFileAttributes attributes) { - if (path.toString().endsWith(".yml")) { - UUID uuid; - try { - uuid = UUID.fromString(path.getFileName().toString().replace(".yml", "")); - } catch (IllegalArgumentException e) { - return FileVisitResult.CONTINUE; - } - - QuestProgressFile file = loadProgressFile(uuid); - if (file != null) { - files.add(file); - } - } - return FileVisitResult.CONTINUE; - } - }; - - try { - Files.walkFileTree(directory.toPath(), fileVisitor); - } catch (IOException e) { - e.printStackTrace(); - } - - return files; - } - - @Override - public void saveAllProgressFiles(List files) { - for (QuestProgressFile file : files) { - saveProgressFile(file.getPlayerUUID(), file); - } - } - - @Override - public boolean isSimilar(StorageProvider provider) { - return provider instanceof YamlStorageProvider; - } -} diff --git a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/util/CommandUtils.java b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/util/CommandUtils.java index b17c63251..56463bf01 100644 --- a/bukkit/src/main/java/com/leonardobishop/quests/bukkit/util/CommandUtils.java +++ b/bukkit/src/main/java/com/leonardobishop/quests/bukkit/util/CommandUtils.java @@ -4,11 +4,12 @@ import com.leonardobishop.quests.bukkit.util.chat.Chat; import com.leonardobishop.quests.common.config.ConfigProblem; import com.leonardobishop.quests.common.player.QPlayer; -import com.leonardobishop.quests.common.player.questprogressfile.QuestProgressFile; import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.OfflinePlayer; import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; import java.util.ArrayList; import java.util.HashMap; @@ -170,17 +171,21 @@ public static void useOtherPlayer(CommandSender sender, String name, BukkitQuest } } - public static void doSafeSave(QPlayer qPlayer, QuestProgressFile questProgressFile, BukkitQuestsPlugin plugin) { - if (Bukkit.getPlayer(qPlayer.getPlayerUUID()) == null) { - plugin.getScheduler().doAsync(() -> { - plugin.getPlayerManager().savePlayerSync(qPlayer.getPlayerUUID(), questProgressFile); - plugin.getScheduler().doSync(() -> { - if (Bukkit.getPlayer(qPlayer.getPlayerUUID()) == null) { + public static void doSafeSave(final @NotNull BukkitQuestsPlugin plugin, final @NotNull QPlayer qPlayer) { + final Player playerBeforeSave = Bukkit.getPlayer(qPlayer.getPlayerUUID()); + + if (playerBeforeSave != null) { + return; + } + + plugin.getPlayerManager() + .savePlayer(qPlayer.getPlayerData()) + .thenAccept(unused -> plugin.getScheduler().doSync(() -> { + final Player playerAfterSave = Bukkit.getPlayer(qPlayer.getPlayerUUID()); + + if (playerAfterSave == null) { plugin.getPlayerManager().dropPlayer(qPlayer.getPlayerUUID()); } - }); - }); - } + })); } - } diff --git a/bukkit/src/main/resources/resources/bukkit/config.yml b/bukkit/src/main/resources/resources/bukkit/config.yml index a550a41b3..8f432f145 100644 --- a/bukkit/src/main/resources/resources/bukkit/config.yml +++ b/bukkit/src/main/resources/resources/bukkit/config.yml @@ -178,16 +178,66 @@ options: password: "" # Address should be in the format ip:port (just like the game itself) address: "localhost:3306" - # This plugin uses 'HikariCP' for connection management, the pooling configuration can be changed here + # This plugin uses HikariCP (https://github.com/brettwooldridge/HikariCP) + # for connection pooling, its configuration can be changed here: connection-pool-settings: - # The maximum number of connections to keep open with the database (def=8) - maximum-pool-size: 8 - # The minimum number of connections to keep open with the database (def=8) + # (*) From HikariCP docs: + # This property controls the minimum number of idle connections that HikariCP tries to maintain + # in the pool. If the idle connections dip below this value and total connections in the pool are + # less than maximum-pool-size, HikariCP will make the best effort to add additional connections + # quickly and efficiently. However, for maximum performance and responsiveness to spike demands, + # we recommend not setting this value and instead allowing HikariCP to act as a fixed size + # connection pool. Default: same as maximum-pool-size + # (*) Quests note: + # The default value has been decreased to 8. minimum-idle: 8 - # The maximum time (in milliseconds) to keep a single connection open (def=1800000 - 30 min) - maximum-lifetime: 1800000 - # The time (in milliseconds) the plugin will wait for a response by the database (def=500) + # (*) From HikariCP docs: + # This property controls the maximum size that the pool is allowed to reach, including both idle and + # in-use connections. Basically this value will determine the maximum number of actual connections to + # the database backend. A reasonable value for this is best determined by your execution environment. + # When the pool reaches this size, and no idle connections are available, calls to getConnection() will + # block for up to connection-timeout milliseconds before timing out. Please read about pool sizing + # (https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing). Default: 10 + # (*) Quests note: + # The default value has been decreased to 8. + maximum-pool-size: 8 + # (*) From HikariCP docs: + # This property controls the maximum number of milliseconds that a client (that's you) will wait for + # a connection from the pool. If this time is exceeded without a connection becoming available, a SQLException + # will be thrown. Lowest acceptable connection timeout is 250 ms. Default: 30000 (30 seconds) + # (*) Quests note: + # The default value has been decreased to 5000 as setting it to 30000 seems a bit too excessive. connection-timeout: 5000 + # (*) From HikariCP docs: + # This property controls the maximum amount of time that a connection is allowed to sit idle in the pool. This + # setting only applies when minimum-idle is defined to be less than maximum-pool-size. Idle connections will not + # be retired once the pool reaches minimum-idle connections. Whether a connection is retired as idle or not is + # subject to a maximum variation of +30 seconds, and average variation of +15 seconds. A connection will never + # be retired as idle before this timeout. A value of 0 means that idle connections are never removed from the + # pool. The minimum allowed value is 10000ms (10 seconds). Default: 600000 (10 minutes) + idle-timeout: 600000 + # (*) From HikariCP docs: + # This property controls how frequently HikariCP will attempt to keep a connection alive, in order to + # prevent it from being timed out by the database or network infrastructure. This value must be less + # than the maximum-lifetime value. A "keepalive" will only occur on an idle connection. When the time + # arrives for a "keepalive" against a given connection, that connection will be removed from the pool, + # "pinged", and then returned to the pool. The 'ping' is one of either: invocation of the JDBC4 isValid() + # method, or execution of the connectionTestQuery. Typically, the duration out-of-the-pool should be + # measured in single digit milliseconds or even sub-millisecond, and therefore should have little or + # no noticeable performance impact. The minimum allowed value is 30000ms (30 seconds), but a value in + # the range of minutes is most desirable. Default: 0 (disabled) + keepalive-time: 0 + # (*) From HikariCP docs: + # This property controls the maximum lifetime of a connection in the pool. An in-use connection will never + # be retired, only when it is closed will it then be removed. On a connection-by-connection basis, minor + # negative attenuation is applied to avoid mass-extinction in the pool. We strongly recommend setting this + # value, and it should be several seconds shorter than any database or infrastructure imposed connection + # time limit. A value of 0 indicates no maximum lifetime (infinite lifetime), subject of course to the + # idle-timeout setting. The minimum allowed value is 30000ms (30 seconds). Default: 1800000 (30 minutes) + maximum-lifetime: 1800000 + # Additional data source properties to be used by the HikariCP connection pool. + # All available properties can be found in the HikariCP docs. + data-source-properties: {} # The prefix each table will use table-prefix: "quests_" diff --git a/common/src/main/java/com/leonardobishop/quests/common/player/QPlayer.java b/common/src/main/java/com/leonardobishop/quests/common/player/QPlayer.java index c3e38e674..bcfafdd05 100644 --- a/common/src/main/java/com/leonardobishop/quests/common/player/QPlayer.java +++ b/common/src/main/java/com/leonardobishop/quests/common/player/QPlayer.java @@ -18,29 +18,52 @@ /** * Represents a player. */ -public class QPlayer { +public final class QPlayer { private final Quests plugin; - private final UUID uuid; - private final QPlayerPreferences playerPreferences; - private final QuestProgressFile questProgressFile; + private final QPlayerData playerData; private QuestController questController; - public QPlayer(Quests plugin, UUID uuid, QPlayerPreferences playerPreferences, QuestProgressFile questProgressFile, QuestController questController) { + public QPlayer(final @NotNull Quests plugin, final @NotNull QPlayerData playerData, final @NotNull QuestController questController) { this.plugin = plugin; - this.uuid = uuid; - this.playerPreferences = playerPreferences; - this.questProgressFile = questProgressFile; + this.playerData = playerData; this.questController = questController; } + /** + * Get this players associated {@link QPlayerData} + * + * @return the players data + */ + public @NotNull QPlayerData getPlayerData() { + return this.playerData; + } + /** * Get the player UUID associated with this quest player. The player may not be online. * * @return uuid */ public @NotNull UUID getPlayerUUID() { - return this.uuid; + return this.playerData.playerUUID(); + } + + /** + * Get this players associated {@link QPlayerPreferences} + * + * @return the players preferences + */ + public @NotNull QPlayerPreferences getPlayerPreferences() { + return this.playerData.playerPreferences(); + } + + /** + * Get this players associated {@link QuestProgressFile} + * + * @return the quest progress file + */ + public @NotNull QuestProgressFile getQuestProgressFile() { + return this.playerData.questProgressFile(); } /** @@ -50,31 +73,32 @@ public QPlayer(Quests plugin, UUID uuid, QPlayerPreferences playerPreferences, Q * @param quest the quest to complete * @return true (always) */ - public boolean completeQuest(@NotNull Quest quest) { + @SuppressWarnings("UnusedReturnValue") + public boolean completeQuest(final @NotNull Quest quest) { Objects.requireNonNull(quest, "quest cannot be null"); - return questController.completeQuestForPlayer(this, quest); + return this.questController.completeQuestForPlayer(this, quest); } /** * Attempt to track a quest for the player. This will also play all effects (such as titles, messages etc.) - ** + * * @param quest the quest to track */ - public void trackQuest(@Nullable Quest quest) { - questController.trackQuestForPlayer(this, quest); + public void trackQuest(final @Nullable Quest quest) { + this.questController.trackQuestForPlayer(this, quest); } /** - * Gets whether or not the player has started a specific quest. + * Gets whether the player has started a specific quest. * * @param quest the quest to test for * @return true if the quest is started or quest autostart is enabled and the quest is ready to start, false otherwise */ - public boolean hasStartedQuest(@NotNull Quest quest) { + public boolean hasStartedQuest(final @NotNull Quest quest) { Objects.requireNonNull(quest, "quest cannot be null"); - return questController.hasPlayerStartedQuest(this, quest); + return this.questController.hasPlayerStartedQuest(this, quest); } /** @@ -129,17 +153,16 @@ public boolean hasStartedQuest(@NotNull Quest quest) { /** * Attempt to start a quest for the player. This will also play all effects (such as titles, messages etc.) - * * Warning: will fail if the player is not online. * * @param quest the quest to start * @return the quest start result -- {@code QuestStartResult.QUEST_SUCCESS} indicates success */ // TODO PlaceholderAPI support - public @NotNull QuestStartResult startQuest(@NotNull Quest quest) { + public @NotNull QuestStartResult startQuest(final @NotNull Quest quest) { Objects.requireNonNull(quest, "quest cannot be null"); - return questController.startQuestForPlayer(this, quest); + return this.questController.startQuestForPlayer(this, quest); } /** @@ -148,10 +171,10 @@ public boolean hasStartedQuest(@NotNull Quest quest) { * @param quest the quest to start * @return true if the quest was cancelled, false otherwise */ - public boolean cancelQuest(@NotNull Quest quest) { + public boolean cancelQuest(final @NotNull Quest quest) { Objects.requireNonNull(quest, "quest cannot be null"); - return questController.cancelQuestForPlayer(this, quest); + return this.questController.cancelQuestForPlayer(this, quest); } /** @@ -160,52 +183,34 @@ public boolean cancelQuest(@NotNull Quest quest) { * @param quest the quest to start * @return true if the quest was expired, false otherwise */ - public boolean expireQuest(@NotNull Quest quest) { + @SuppressWarnings("UnusedReturnValue") + public boolean expireQuest(final @NotNull Quest quest) { Objects.requireNonNull(quest, "quest cannot be null"); - return questController.expireQuestForPlayer(this, quest); + return this.questController.expireQuestForPlayer(this, quest); } /** * Check if the player can start a quest. - * * Warning: will fail if the player is not online. * * @param quest the quest to check * @return the quest start result */ - public @NotNull QuestStartResult canStartQuest(@NotNull Quest quest) { + public @NotNull QuestStartResult canStartQuest(final @NotNull Quest quest) { Objects.requireNonNull(quest, "quest cannot be null"); - return questController.canPlayerStartQuest(this, quest); + return this.questController.canPlayerStartQuest(this, quest); } /** - * Get this players associated {@link QuestProgressFile} - * - * @return the quest progress file - */ - public @NotNull QuestProgressFile getQuestProgressFile() { - return questProgressFile; - } - - /** - * Get this players associated {@link QPlayerPreferences} - * - * @return the players preferences - */ - public @NotNull QPlayerPreferences getPlayerPreferences() { - return playerPreferences; - } - - /** - * Get this players associated {@link QuestController}, usually the servers active quest controller + * Get player's associated {@link QuestController}. It's usually the server's active quest controller. * * @see QPlayerManager#getActiveQuestController() * @return the quest controller for this player */ public @NotNull QuestController getQuestController() { - return questController; + return this.questController; } /** @@ -213,21 +218,24 @@ public boolean expireQuest(@NotNull Quest quest) { * * @param questController new quest controller */ - public void setQuestController(@NotNull QuestController questController) { + public void setQuestController(final @NotNull QuestController questController) { Objects.requireNonNull(questController, "questController cannot be null"); this.questController = questController; } - @Override //Used by java GC - public boolean equals(Object o) { - if (!(o instanceof QPlayer)) return false; - QPlayer qPlayer = (QPlayer) o; - return this.uuid == qPlayer.getPlayerUUID(); + @Override + public boolean equals(final @Nullable Object o) { + if (o instanceof final QPlayer qPlayer) { + return this.getPlayerUUID() == qPlayer.getPlayerUUID(); + } else { + return false; + } } - @Override //Used by java GC + @Override public int hashCode() { - return uuid.hashCode() * 73; //uuid hash * prime number + // uuid hash * prime number + return this.getPlayerUUID().hashCode() * 73; } } diff --git a/common/src/main/java/com/leonardobishop/quests/common/player/QPlayerData.java b/common/src/main/java/com/leonardobishop/quests/common/player/QPlayerData.java new file mode 100644 index 000000000..cdabd3850 --- /dev/null +++ b/common/src/main/java/com/leonardobishop/quests/common/player/QPlayerData.java @@ -0,0 +1,44 @@ +package com.leonardobishop.quests.common.player; + +import com.leonardobishop.quests.common.player.questprogressfile.QuestProgressFile; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; +import java.util.UUID; + +public final class QPlayerData { + + private final UUID playerUUID; + private final QPlayerPreferences playerPreferences; + private final QuestProgressFile questProgressFile; + + public QPlayerData(final @NotNull UUID playerUUID, final @NotNull QPlayerPreferences playerPreferences, final @NotNull QuestProgressFile questProgressFile) { + this.playerUUID = Objects.requireNonNull(playerUUID, "playerUUID cannot be null"); + this.playerPreferences = Objects.requireNonNull(playerPreferences, "playerPreferences cannot be null"); + this.questProgressFile = Objects.requireNonNull(questProgressFile, "questProgressFile cannot be null"); + } + + public QPlayerData(final @NotNull QPlayerData playerData) { + Objects.requireNonNull(playerData, "playerData cannot be null"); + + this.playerUUID = playerData.playerUUID; + this.playerPreferences = playerData.playerPreferences; + this.questProgressFile = new QuestProgressFile(playerData.questProgressFile); + } + + public @NotNull UUID playerUUID() { + return this.playerUUID; + } + + public @NotNull QPlayerPreferences playerPreferences() { + return this.playerPreferences; + } + + public @NotNull QuestProgressFile questProgressFile() { + return this.questProgressFile; + } + + public void setModified(final boolean modified) { + this.questProgressFile.setModified(modified); + } +} diff --git a/common/src/main/java/com/leonardobishop/quests/common/player/QPlayerManager.java b/common/src/main/java/com/leonardobishop/quests/common/player/QPlayerManager.java index 1475c44a8..4c3704deb 100644 --- a/common/src/main/java/com/leonardobishop/quests/common/player/QPlayerManager.java +++ b/common/src/main/java/com/leonardobishop/quests/common/player/QPlayerManager.java @@ -6,6 +6,7 @@ import com.leonardobishop.quests.common.storage.StorageProvider; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnmodifiableView; import java.util.Collection; import java.util.Collections; @@ -19,17 +20,18 @@ * The QPlayerManager is responsible for keeping a reference to all players on the server and is used to * obtain an instance of a player, load new players and save current players. */ -public class QPlayerManager { +public final class QPlayerManager { - private final Map qPlayers = new ConcurrentHashMap<>(); private final Quests plugin; private final StorageProvider storageProvider; + private final Map qPlayerMap; private QuestController activeQuestController; - public QPlayerManager(Quests plugin, StorageProvider storageProvider, QuestController questController) { - this.plugin = plugin; - this.storageProvider = storageProvider; - this.activeQuestController = questController; + public QPlayerManager(final @NotNull Quests plugin, final @NotNull StorageProvider storageProvider, final @NotNull QuestController questController) { + this.plugin = Objects.requireNonNull(plugin, "plugin cannot be null"); + this.storageProvider = Objects.requireNonNull(storageProvider, "storageProvider cannot be null"); + this.activeQuestController = Objects.requireNonNull(questController, "questController cannot be null"); + this.qPlayerMap = new ConcurrentHashMap<>(); } /** @@ -38,7 +40,7 @@ public QPlayerManager(Quests plugin, StorageProvider storageProvider, QuestContr * @param uuid the uuid * @return {@link QPlayer} if they are loaded, otherwise null */ - public @Nullable QPlayer getPlayer(@NotNull UUID uuid) { + public @Nullable QPlayer getPlayer(final @NotNull UUID uuid) { Objects.requireNonNull(uuid, "uuid cannot be null"); // QPlayer qPlayer = qPlayers.get(uuid); @@ -48,7 +50,7 @@ public QPlayerManager(Quests plugin, StorageProvider storageProvider, QuestContr // Thread.dumpStack(); // } // } - return qPlayers.get(uuid); + return this.qPlayerMap.get(uuid); } /** @@ -56,12 +58,12 @@ public QPlayerManager(Quests plugin, StorageProvider storageProvider, QuestContr * * @param uuid the uuid of the player */ - public void removePlayer(@NotNull UUID uuid) { + public void removePlayer(final @NotNull UUID uuid) { Objects.requireNonNull(uuid, "uuid cannot be null"); - plugin.getQuestsLogger().debug("Unloading and saving player " + uuid + "..."); - CompletableFuture future = savePlayer(uuid); - future.thenAccept((v) -> qPlayers.remove(uuid)); + this.plugin.getQuestsLogger().debug("Unloading and saving player " + uuid + "..."); + final CompletableFuture future = this.savePlayer(uuid); + future.thenAccept(unused -> this.qPlayerMap.remove(uuid)); } /** @@ -71,32 +73,30 @@ public void removePlayer(@NotNull UUID uuid) { * @param uuid the uuid of the player * @return completable future */ - public CompletableFuture savePlayer(@NotNull UUID uuid) { + public @NotNull CompletableFuture savePlayer(final @NotNull UUID uuid) { Objects.requireNonNull(uuid, "uuid cannot be null"); - QPlayer qPlayer = getPlayer(uuid); - if (qPlayer == null) return CompletableFuture.completedFuture(null); - return savePlayer(uuid, qPlayer.getQuestProgressFile()); + final QPlayer qPlayer = this.getPlayer(uuid); + if (qPlayer == null) { + return CompletableFuture.completedFuture(null); + } + + return this.savePlayer(qPlayer.getPlayerData()); } /** * Schedules a save for the player with a specified {@link QuestProgressFile}. The modified status of the * specified progress file will be reset. - * - * @param uuid the uuid of the player - * @param originalProgressFile the quest progress file to associate with and save - * @return completable future */ - public CompletableFuture savePlayer(@NotNull UUID uuid, @NotNull QuestProgressFile originalProgressFile) { - Objects.requireNonNull(uuid, "uuid cannot be null"); - Objects.requireNonNull(originalProgressFile, "originalProgressFile cannot be null"); + public @NotNull CompletableFuture savePlayer(final @NotNull QPlayerData playerData) { + Objects.requireNonNull(playerData, "playerData cannot be null"); - CompletableFuture future = new CompletableFuture<>(); + final CompletableFuture future = new CompletableFuture<>(); + final QPlayerData clonedPlayerData = new QPlayerData(playerData); + playerData.setModified(false); - QuestProgressFile clonedProgressFile = new QuestProgressFile(originalProgressFile); - originalProgressFile.resetModified(); - plugin.getScheduler().doAsync(() -> { - save(uuid, clonedProgressFile); + this.plugin.getScheduler().doAsync(() -> { + this.save(clonedPlayerData); future.complete(null); }); @@ -109,34 +109,35 @@ public CompletableFuture savePlayer(@NotNull UUID uuid, @NotNull QuestProg * * @param uuid the uuid of the player */ - public void savePlayerSync(@NotNull UUID uuid) { + public void savePlayerSync(final @NotNull UUID uuid) { Objects.requireNonNull(uuid, "uuid cannot be null"); - QPlayer qPlayer = getPlayer(uuid); - if (qPlayer == null) return; - savePlayerSync(uuid, qPlayer.getQuestProgressFile()); + final QPlayer qPlayer = this.getPlayer(uuid); + if (qPlayer == null) { + return; + } + + this.savePlayerSync(qPlayer.getPlayerData()); } /** * Immediately saves the player with a specified {@link QuestProgressFile}, on the same thread. The modified status * of the specified progress file is not changed. - * - * @param uuid the uuid of the player - * @param questProgressFile the quest progress file to associate with and save */ - public void savePlayerSync(@NotNull UUID uuid, @NotNull QuestProgressFile questProgressFile) { - save(uuid, questProgressFile); + public void savePlayerSync(final @NotNull QPlayerData playerData) { + this.save(playerData); } - private void save(@NotNull UUID uuid, @NotNull QuestProgressFile questProgressFile) { - Objects.requireNonNull(uuid, "uuid cannot be null"); - Objects.requireNonNull(questProgressFile, "questProgressFile cannot be null"); + private void save(@NotNull QPlayerData playerData) { + Objects.requireNonNull(playerData, "playerData cannot be null"); + + final String uuidString = playerData.playerUUID().toString(); + this.plugin.getQuestsLogger().debug("Saving player " + uuidString + "..."); - plugin.getQuestsLogger().debug("Saving player " + uuid + "..."); - if (storageProvider.saveProgressFile(uuid, questProgressFile)) { - plugin.getQuestsLogger().debug("Quest progress file saved for player " + uuid + "."); + if (this.storageProvider.savePlayerData(playerData)) { + this.plugin.getQuestsLogger().debug("Quest progress file saved for player " + uuidString + "."); } else { - plugin.getQuestsLogger().severe("Failed to save player " + uuid + "!"); + this.plugin.getQuestsLogger().severe("Failed to save player " + uuidString + "!"); } } @@ -145,20 +146,21 @@ private void save(@NotNull UUID uuid, @NotNull QuestProgressFile questProgressFi * * @param uuid the uuid of the player */ - public void dropPlayer(@NotNull UUID uuid) { + public void dropPlayer(final @NotNull UUID uuid) { Objects.requireNonNull(uuid, "uuid cannot be null"); - plugin.getQuestsLogger().debug("Dropping player " + uuid + "."); - qPlayers.remove(uuid); + this.plugin.getQuestsLogger().debug("Dropping player " + uuid + "."); + this.qPlayerMap.remove(uuid); } /** * Gets all QPlayers loaded on the server * - * @return immutable map of quest players + * @return immutable collection of quest players */ - public Collection getQPlayers() { - return Collections.unmodifiableCollection(qPlayers.values()); + @UnmodifiableView + public @NotNull Collection getQPlayers() { + return Collections.unmodifiableCollection(this.qPlayerMap.values()); } /** @@ -168,20 +170,26 @@ public Collection getQPlayers() { * @param uuid the uuid of the player * @return completable future with the loaded player, or null if there was an error */ - public CompletableFuture loadPlayer(UUID uuid) { - plugin.getQuestsLogger().debug("Loading player " + uuid + "..."); - - CompletableFuture future = new CompletableFuture<>(); - plugin.getScheduler().doAsync(() -> { - QuestProgressFile questProgressFile = storageProvider.loadProgressFile(uuid); - if (questProgressFile == null) { - plugin.getQuestsLogger().debug("A problem occurred trying loading player " + uuid + "; quest progress file is null."); + public @NotNull CompletableFuture loadPlayer(final @NotNull UUID uuid) { + Objects.requireNonNull(uuid, "uuid cannot be null"); + + final String uuidString = uuid.toString(); + this.plugin.getQuestsLogger().debug("Loading player " + uuidString + "..."); + final CompletableFuture future = new CompletableFuture<>(); + + this.plugin.getScheduler().doAsync(() -> { + final QPlayerData playerData = this.storageProvider.loadPlayerData(uuid); + + if (playerData == null) { + this.plugin.getQuestsLogger().debug("A problem occurred trying loading player " + uuidString + "; quest progress file is null."); future.complete(null); return; } - QPlayer qPlayer = new QPlayer(plugin, uuid, new QPlayerPreferences(null), questProgressFile, activeQuestController); - qPlayers.computeIfAbsent(uuid, s -> qPlayer); - plugin.getQuestsLogger().debug("Quest progress file loaded for player " + uuid + "."); + + final QPlayer qPlayer = new QPlayer(this.plugin, playerData, this.activeQuestController); + this.qPlayerMap.putIfAbsent(uuid, qPlayer); + + this.plugin.getQuestsLogger().debug("Quest progress file loaded for player " + uuidString + "."); future.complete(qPlayer); }); @@ -193,17 +201,18 @@ public CompletableFuture loadPlayer(UUID uuid) { * * @return {@link StorageProvider} */ - public StorageProvider getStorageProvider() { - return storageProvider; + public @NotNull StorageProvider getStorageProvider() { + return this.storageProvider; } - public QuestController getActiveQuestController() { - return activeQuestController; + public @NotNull QuestController getActiveQuestController() { + return this.activeQuestController; } - public void setActiveQuestController(QuestController activeQuestController) { - this.activeQuestController = activeQuestController; - for (QPlayer qPlayer : qPlayers.values()) { + public void setActiveQuestController(final @NotNull QuestController activeQuestController) { + this.activeQuestController = Objects.requireNonNull(activeQuestController, "activeQuestController cannot be null"); + + for (final QPlayer qPlayer : this.qPlayerMap.values()) { qPlayer.setQuestController(activeQuestController); } } diff --git a/common/src/main/java/com/leonardobishop/quests/common/player/questprogressfile/QuestProgress.java b/common/src/main/java/com/leonardobishop/quests/common/player/questprogressfile/QuestProgress.java index 8e51d7694..d8506b83b 100644 --- a/common/src/main/java/com/leonardobishop/quests/common/player/questprogressfile/QuestProgress.java +++ b/common/src/main/java/com/leonardobishop/quests/common/player/questprogressfile/QuestProgress.java @@ -1,19 +1,23 @@ package com.leonardobishop.quests.common.player.questprogressfile; import com.leonardobishop.quests.common.plugin.Quests; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.Collection; import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.UUID; -public class QuestProgress { +public final class QuestProgress { private final Quests plugin; - - private final Map taskProgress = new HashMap<>(); - private final String questid; - private final UUID player; + private final String questId; + private final UUID playerUUID; + private final Map taskProgressMap; private boolean started; private long startedDate; @@ -22,29 +26,66 @@ public class QuestProgress { private long completionDate; private boolean modified; - public QuestProgress(Quests plugin, String questid, boolean completed, boolean completedBefore, long completionDate, UUID player, boolean started, long startedDate) { + /** + * Constructs a QuestProgress. + * + * @param plugin the plugin instance + * @param questId the associated quest ID + * @param playerUUID the associated player UUID + * @param started whether the quest is started + * @param startedDate the date of the last quest start + * @param completed whether the quest is completed + * @param completedBefore whether the quest has been completed before + * @param completionDate the date of the last quest completion + * @param modified whether the object has been modified and needs to be saved + */ + public QuestProgress(final @NotNull Quests plugin, final @NotNull String questId, final @NotNull UUID playerUUID, final boolean started, final long startedDate, final boolean completed, final boolean completedBefore, final long completionDate, final boolean modified) { this.plugin = plugin; - this.questid = questid; + this.questId = questId; + this.playerUUID = playerUUID; + this.taskProgressMap = new HashMap<>(); + this.started = started; + this.startedDate = startedDate; this.completed = completed; this.completedBefore = completedBefore; this.completionDate = completionDate; - this.player = player; - this.started = started; - this.startedDate = startedDate; + this.modified = modified; } - public QuestProgress(Quests plugin, String questid, boolean completed, boolean completedBefore, long completionDate, UUID player, boolean started, long startedDate, boolean modified) { - this(plugin, questid, completed, completedBefore, completionDate, player, started, startedDate); - this.modified = modified; + /** + * Constructs a QuestProgress with {@link QuestProgress#modified} set to {@code false}. + * + * @param plugin the plugin instance + * @param questId the associated quest ID + * @param playerUUID the associated player UUID + * @param started whether the quest is started + * @param startedDate the date of the last quest start + * @param completed whether the quest is completed + * @param completedBefore whether the quest has been completed before + * @param completionDate the date of the last quest completion + */ + public QuestProgress(final @NotNull Quests plugin, final @NotNull String questId, final @NotNull UUID playerUUID, final boolean started, final long startedDate, final boolean completed, final boolean completedBefore, final long completionDate) { + this(plugin, questId, playerUUID, started, startedDate, completed, completedBefore, completionDate, false); } - public QuestProgress(QuestProgress questProgress) { + /** + * Constructs a data-only clone from a QuestProgress instance. + * + * @param questProgress the quest progress instance + */ + @ApiStatus.Internal + public QuestProgress(final @NotNull QuestProgress questProgress) { + final Set> progressEntries = questProgress.taskProgressMap.entrySet(); + this.plugin = questProgress.plugin; - for (Map.Entry progressEntry : questProgress.taskProgress.entrySet()) { - taskProgress.put(progressEntry.getKey(), new TaskProgress(progressEntry.getValue())); + this.questId = questProgress.questId; + this.playerUUID = questProgress.playerUUID; + this.taskProgressMap = new HashMap<>(progressEntries.size()); + + for (final Map.Entry progressEntry : progressEntries) { + this.taskProgressMap.put(progressEntry.getKey(), new TaskProgress(progressEntry.getValue())); } - this.questid = questProgress.questid; - this.player = questProgress.player; + this.started = questProgress.started; this.startedDate = questProgress.startedDate; this.completed = questProgress.completed; @@ -53,120 +94,259 @@ public QuestProgress(QuestProgress questProgress) { this.modified = questProgress.modified; } - public String getQuestId() { - return questid; + /** + * @return the associated quest ID + */ + @Contract(pure = true) + public @NotNull String getQuestId() { + return this.questId; } - public boolean isCompleted() { - return completed; + /** + * @return the associated player ID + * @see QuestProgress#getPlayerUUID() + */ + @Deprecated(forRemoval = true) + @Contract(pure = true) + public @NotNull UUID getPlayer() { + return this.playerUUID; } - public void setCompleted(boolean completed) { - this.completed = completed; - this.modified = true; + /** + * @return the associated player ID + */ + @Contract(pure = true) + public @NotNull UUID getPlayerUUID() { + return this.playerUUID; + } + + /** + * @return mutable task progress map + */ + @Contract(pure = true) + public @NotNull Map getTaskProgressMap() { + return this.taskProgressMap; } + /** + * @return mutable task progress map values collection + * @see QuestProgress#getTaskProgress() + */ + @Deprecated(forRemoval = true) + @Contract(pure = true) + public @NotNull Collection getTaskProgress() { + return this.taskProgressMap.values(); + } + + /** + * @return mutable task progress map values collection + */ + @SuppressWarnings("unused") + @Contract(pure = true) + public @NotNull Collection getAllTaskProgress() { + return this.taskProgressMap.values(); + } + + /** + * Gets the {@link TaskProgress} for a specified task ID. Generates a new one if it does not exist. + * + * @param taskId the task ID to get the progress for + * @return {@link TaskProgress} or a blank generated one if the task does not exist + */ + public @NotNull TaskProgress getTaskProgress(final @NotNull String taskId) { + final TaskProgress taskProgress = this.taskProgressMap.get(taskId); + if (taskProgress != null) { + return taskProgress; + } + + final TaskProgress newTaskProgress = new TaskProgress(this, taskId, this.playerUUID, null, false, false); + this.addTaskProgress(newTaskProgress); + return newTaskProgress; + } + + /** + * Gets the {@link TaskProgress} for a specified task ID. Returns null if it does not exist. + * + * @param taskId the task ID to get the progress for + * @return {@link TaskProgress} or null if the task does not exist + */ + @SuppressWarnings("unused") + @Contract(pure = true) + public @Nullable TaskProgress getTaskProgressOrNull(final @NotNull String taskId) { + return this.taskProgressMap.get(taskId); + } + + /** + * @param taskId the task ID to repair the progress for + */ + @Deprecated(forRemoval = true) + @ApiStatus.Internal + public void repairTaskProgress(final @NotNull String taskId) { + final TaskProgress taskProgress = new TaskProgress(this, taskId, this.playerUUID, null, false, false); + this.addTaskProgress(taskProgress); + } + + /** + * @param taskProgress the task progress to put into the task progress map + */ + public void addTaskProgress(final @NotNull TaskProgress taskProgress) { + this.taskProgressMap.put(taskProgress.getTaskId(), taskProgress); + } + + /** + * @return whether the quest is started + */ + @Contract(pure = true) public boolean isStarted() { - return started; + return this.started; } - public void setStarted(boolean started) { + /** + * @param started whether the quest is started + */ + public void setStarted(final boolean started) { this.started = started; this.modified = true; } + /** + * @return the date of the last quest start + */ + @Contract(pure = true) public long getStartedDate() { - return startedDate; + return this.startedDate; } - public void setStartedDate(long startedDate) { + /** + * @param startedDate the date of the last quest start + */ + public void setStartedDate(final long startedDate) { this.startedDate = startedDate; this.modified = true; } - public long getCompletionDate() { - return completionDate; + /** + * @return whether the quest is completed + */ + @Contract(pure = true) + public boolean isCompleted() { + return this.completed; } - public void setCompletionDate(long completionDate) { - this.completionDate = completionDate; + /** + * @param completed whether the quest is completed + */ + public void setCompleted(final boolean completed) { + this.completed = completed; this.modified = true; } - public UUID getPlayer() { - return player; - } - + /** + * @return whether the quest has been completed before + */ + @Contract(pure = true) public boolean isCompletedBefore() { - return completedBefore; + return this.completedBefore; } - public void setCompletedBefore(boolean completedBefore) { + /** + * @param completedBefore whether the quest has been completed before + */ + public void setCompletedBefore(final boolean completedBefore) { this.completedBefore = completedBefore; this.modified = true; } - public void addTaskProgress(TaskProgress taskProgress) { - this.taskProgress.put(taskProgress.getTaskId(), taskProgress); + /** + * @return the date of the last quest completion + */ + @Contract(pure = true) + public long getCompletionDate() { + return this.completionDate; } - public Collection getTaskProgress() { - return taskProgress.values(); + /** + * @param completionDate the date of the last quest completion + */ + public void setCompletionDate(final long completionDate) { + this.completionDate = completionDate; + this.modified = true; } - public Map getTaskProgressMap() { - return taskProgress; - } + /** + * @return whether the object has been modified and needs to be saved + */ + @SuppressWarnings("unused") + @Contract(pure = true) + public boolean isModified() { + if (this.modified) { + return true; + } - public TaskProgress getTaskProgress(String taskId) { - TaskProgress tP = taskProgress.getOrDefault(taskId, null); - if (tP == null) { - repairTaskProgress(taskId); - tP = taskProgress.getOrDefault(taskId, null); + for (final TaskProgress taskProgress : this.taskProgressMap.values()) { + if (taskProgress.isModified()) { + return true; + } } - return tP; + + return false; } - public void repairTaskProgress(String taskid) { - TaskProgress taskProgress = new TaskProgress(this, taskid, null, player, false, false); - this.addTaskProgress(taskProgress); + /** + * It's equivalent to {@code QuestProgress#setModified(false)}. + * + * @see QuestProgress#setModified(boolean) + */ + @Deprecated(forRemoval = true) + public void resetModified() { + this.setModified(false); } - public boolean isModified() { - if (modified) return true; - else { - for (TaskProgress progress : this.taskProgress.values()) { - if (progress.isModified()) return true; - } - return false; + /** + * @param modified whether the object has been modified and needs to be saved + */ + public void setModified(final boolean modified) { + this.modified = modified; + + for (final TaskProgress taskProgress : this.taskProgressMap.values()) { + taskProgress.setModified(modified); } } + /** + * Gets whether the object has non default values. + * + *

+ * Fields checked are:
+ * - {@link QuestProgress#started}
+ * - {@link QuestProgress#startedDate}
+ * - {@link QuestProgress#completed}
+ * - {@link QuestProgress#completedBefore}
+ * - {@link QuestProgress#completionDate} + *

+ * + * @return whether the object has non default values + */ + @Contract(pure = true) public boolean hasNonDefaultValues() { - if (this.started || this.startedDate != 0 || this.completed || this.completedBefore || this.completionDate != 0) return true; - else { - for (TaskProgress progress : this.taskProgress.values()) { - if (progress.getProgress() != null || progress.isCompleted()) return true; - } - return false; + if (this.started || this.startedDate != 0L || this.completed || this.completedBefore || this.completionDate != 0L) { + return true; } - } - public void queueForCompletionTest() { - plugin.getQuestCompleter().queueSingular(this); - } - - public void resetModified() { - this.modified = false; - for (TaskProgress progress : this.taskProgress.values()) { - progress.resetModified(); + for (final TaskProgress taskProgress : this.taskProgressMap.values()) { + if (taskProgress.getProgress() != null || taskProgress.isCompleted()) { + return true; + } } + + return false; } - public void setModified(boolean modified) { - this.modified = modified; - for (TaskProgress progress : this.taskProgress.values()) { - progress.setModified(modified); - } + /** + * Queues the {@link QuestProgress} instance for a completion test. + */ + @SuppressWarnings("unused") + public void queueForCompletionTest() { + this.plugin.getQuestCompleter().queueSingular(this); } } diff --git a/common/src/main/java/com/leonardobishop/quests/common/player/questprogressfile/QuestProgressFile.java b/common/src/main/java/com/leonardobishop/quests/common/player/questprogressfile/QuestProgressFile.java index 0069eb127..7cadaa9a6 100644 --- a/common/src/main/java/com/leonardobishop/quests/common/player/questprogressfile/QuestProgressFile.java +++ b/common/src/main/java/com/leonardobishop/quests/common/player/questprogressfile/QuestProgressFile.java @@ -4,62 +4,80 @@ import com.leonardobishop.quests.common.plugin.Quests; import com.leonardobishop.quests.common.quest.Quest; import com.leonardobishop.quests.common.quest.Task; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.TimeUnit; /** * Represents underlying quest progress for a player. */ -public class QuestProgressFile { +public final class QuestProgressFile { - private final Map questProgress = new HashMap<>(); - private final UUID playerUUID; private final Quests plugin; + private final UUID playerUUID; + private final Map questProgressMap; - public QuestProgressFile(UUID playerUUID, Quests plugin) { - this.playerUUID = playerUUID; + /** + * Constructs a QuestProgressFile. + * + * @param plugin the plugin instance + * @param playerUUID the associated player UUID + */ + public QuestProgressFile(final @NotNull Quests plugin, final @NotNull UUID playerUUID) { this.plugin = plugin; + this.playerUUID = playerUUID; + this.questProgressMap = new HashMap<>(); } - public QuestProgressFile(QuestProgressFile questProgressFile) { - for (Map.Entry progressEntry : questProgressFile.questProgress.entrySet()) { - questProgress.put(progressEntry.getKey(), new QuestProgress(progressEntry.getValue())); - } - this.playerUUID = questProgressFile.playerUUID; + /** + * Constructs a data-only clone from a QuestProgressFile instance. + * + * @param questProgressFile the quest progress file instance + */ + @ApiStatus.Internal + public QuestProgressFile(final @NotNull QuestProgressFile questProgressFile) { + final Set> progressEntries = questProgressFile.questProgressMap.entrySet(); + this.plugin = questProgressFile.plugin; + this.playerUUID = questProgressFile.playerUUID; + this.questProgressMap = new HashMap<>(progressEntries.size()); + + for (final Map.Entry progressEntry : progressEntries) { + this.questProgressMap.put(progressEntry.getKey(), new QuestProgress(progressEntry.getValue())); + } } - public void addQuestProgress(QuestProgress questProgress) { - //TODO don't do here -// if (Options.VERIFY_QUEST_EXISTS_ON_LOAD.getBooleanValue(true) && plugin.getQuestManager().getQuestById(questProgress.getQuestId()) == null) { -// return; -// } - this.questProgress.put(questProgress.getQuestId(), questProgress); + /** + * @param questProgress the quest progress to put into the quest progress map + */ + public void addQuestProgress(final @NotNull QuestProgress questProgress) { + // TODO don't do that here + //if (Options.VERIFY_QUEST_EXISTS_ON_LOAD.getBooleanValue(true) && plugin.getQuestManager().getQuestById(questProgress.getQuestId()) == null) { + // return; + //} + this.questProgressMap.put(questProgress.getQuestId(), questProgress); } /** - * Gets all manually started quests. - * Note: if quest autostart is enabled then this may produce unexpected results as quests are - * not "started" by the player if autostart is true. Consider {@link QPlayer#hasStartedQuest(Quest)} - * or {@link QPlayer#getEffectiveStartedQuests()} instead. + * Gets all manually started quests. If quest autostart is enabled then this may produce unexpected results as + * quests are not "started" by the player if autostart is true. Consider {@link QPlayer#hasStartedQuest(Quest)} + * or {@link QPlayer#getEffectiveStartedQuests()} usage instead. * * @return list of started quests */ - public List getStartedQuests() { - List startedQuests = new ArrayList<>(); - for (QuestProgress questProgress : questProgress.values()) { - Quest quest = plugin.getQuestManager().getQuestById(questProgress.getQuestId()); - if (quest != null && questProgress.isStarted()) { - startedQuests.add(plugin.getQuestManager().getQuestById(questProgress.getQuestId())); - } - } - return startedQuests; + @Contract(pure = true) + public @NotNull List getStartedQuests() { + return this.getAllQuestsFromProgress(QuestsProgressFilter.STARTED); } /** @@ -68,50 +86,86 @@ public List getStartedQuests() { * * @return {@code List} all quests */ - public List getAllQuestsFromProgress(QuestsProgressFilter filter) { - List questsProgress = new ArrayList<>(); - for (QuestProgress qProgress : questProgress.values()) { - boolean condition = false; - if (filter == QuestsProgressFilter.STARTED) { - condition = qProgress.isStarted(); - } else if (filter == QuestsProgressFilter.COMPLETED_BEFORE) { - condition = qProgress.isCompletedBefore(); - } else if (filter == QuestsProgressFilter.COMPLETED) { - condition = qProgress.isCompleted(); - } else if (filter == QuestsProgressFilter.ALL) { - condition = true; + @Contract(pure = true) + public @NotNull List getAllQuestsFromProgress(final @NotNull QuestsProgressFilter filter) { + final List quests = new ArrayList<>(); + + for (final QuestProgress questProgress : this.questProgressMap.values()) { + final boolean matches = filter.matches(questProgress); + if (!matches) { + continue; } - if (condition) { - Quest quest = plugin.getQuestManager().getQuestById(qProgress.getQuestId()); - if (quest != null) { - questsProgress.add(quest); - } + + final Quest quest = this.plugin.getQuestManager().getQuestById(questProgress.getQuestId()); + if (quest == null) { + continue; } + + quests.add(quest); } - return questsProgress; + + return quests; } public enum QuestsProgressFilter { - ALL("all"), - COMPLETED("completed"), - COMPLETED_BEFORE("completedBefore"), - STARTED("started"); + ALL("all") { + @Override + @Contract(pure = true) + public boolean matches(final @NotNull QuestProgress questProgress) { + return true; + } + }, + COMPLETED("completed") { + @Override + @Contract(pure = true) + public boolean matches(final @NotNull QuestProgress questProgress) { + return questProgress.isCompleted(); + } + }, + COMPLETED_BEFORE("completedBefore") { + @Override + @Contract(pure = true) + public boolean matches(final @NotNull QuestProgress questProgress) { + return questProgress.isCompletedBefore(); + } + }, + STARTED("started") { + @Override + @Contract(pure = true) + public boolean matches(final @NotNull QuestProgress questProgress) { + return questProgress.isStarted(); + } + }; private final String legacy; - QuestsProgressFilter(String legacy) { + QuestsProgressFilter(final @NotNull String legacy) { this.legacy = legacy; } - public static QuestsProgressFilter fromLegacy(String filter) { - for (QuestsProgressFilter filterEnum : QuestsProgressFilter.values()) { - if (filterEnum.getLegacy().equals(filter)) return filterEnum; - } - return QuestsProgressFilter.ALL; + @SuppressWarnings("unused") + @Contract(pure = true) + public @NotNull String getLegacy() { + return this.legacy; } - public String getLegacy() { - return legacy; + @Contract(pure = true) + public abstract boolean matches(final @NotNull QuestProgress questProgress); + + // And some static things to improve legacy performance (is it even used?) + + private static final QuestsProgressFilter[] FILTERS = QuestsProgressFilter.values(); + + private static final Map legacyToFilterMap = new HashMap<>(QuestsProgressFilter.FILTERS.length) {{ + for (final QuestsProgressFilter questsProgressFilter : QuestsProgressFilter.FILTERS) { + this.put(questsProgressFilter.legacy, questsProgressFilter); + } + }}; + + @SuppressWarnings("unused") + @Contract(pure = true) + public static @NotNull QuestsProgressFilter fromLegacy(final @NotNull String legacy) { + return QuestsProgressFilter.legacyToFilterMap.getOrDefault(legacy, QuestsProgressFilter.ALL); } } @@ -120,35 +174,50 @@ public String getLegacy() { * * @return {@code Collection} all quest progresses */ - public Collection getAllQuestProgress() { - return questProgress.values(); + @Contract(pure = true) + public @NotNull Collection getAllQuestProgress() { + return this.questProgressMap.values(); } /** - * Checks whether or not the player has {@link QuestProgress} for a specified quest + * Checks whether the player has {@link QuestProgress} for a specified quest * * @param quest the quest to check for * @return true if they have quest progress */ - public boolean hasQuestProgress(Quest quest) { - return questProgress.containsKey(quest.getId()); + @Contract(pure = true) + public boolean hasQuestProgress(final @NotNull Quest quest) { + return this.questProgressMap.containsKey(quest.getId()); } /** * Gets the remaining cooldown before being able to start a specific quest. * * @param quest the quest to test for - * @return 0 if no cooldown remaining or the cooldown is disabled, otherwise the cooldown in milliseconds + * @return 0 if no cooldown remaining, -1 if the cooldown is disabled or the quest is not completed, + * otherwise the cooldown in milliseconds */ - public long getCooldownFor(Quest quest) { - QuestProgress questProgress = getQuestProgress(quest); - if (quest.isCooldownEnabled() && questProgress.isCompleted()) { - if (questProgress.getCompletionDate() > 0) { - long date = questProgress.getCompletionDate(); - return (date + TimeUnit.MILLISECONDS.convert(quest.getCooldown(), TimeUnit.MINUTES)) - System.currentTimeMillis(); - } + @Contract(pure = true) + public long getCooldownFor(final @NotNull Quest quest) { + if (!quest.isCooldownEnabled()) { + return -1; + } + + final QuestProgress questProgress = this.getQuestProgressOrNull(quest); + if (questProgress == null || !questProgress.isCompleted()) { + return -1; + } + + final long completionDate = questProgress.getCompletionDate(); + if (completionDate == 0) { + return -1; } - return 0; + + final long currentTimeMillis = System.currentTimeMillis(); + final long cooldownMillis = TimeUnit.MILLISECONDS.convert(quest.getCooldown(), TimeUnit.MINUTES); + + // do the subtraction first to prevent overflow + return Math.max(0L, completionDate - currentTimeMillis + cooldownMillis); } /** @@ -158,101 +227,134 @@ public long getCooldownFor(Quest quest) { * @return 0 if no time remaining, -1 if the time limit is disabled or the quest is not started, * otherwise the time left in milliseconds */ - public long getTimeRemainingFor(Quest quest) { - QuestProgress questProgress = getQuestProgress(quest); - if (quest.isTimeLimitEnabled() && questProgress.isStarted()) { - return Math.max( - questProgress.getStartedDate() - + TimeUnit.MILLISECONDS.convert(quest.getTimeLimit(), TimeUnit.MINUTES) - - System.currentTimeMillis() - , 0); + @Contract(pure = true) + public long getTimeRemainingFor(final @NotNull Quest quest) { + if (!quest.isTimeLimitEnabled()) { + return -1; + } + + final QuestProgress questProgress = this.getQuestProgressOrNull(quest); + if (questProgress == null || !questProgress.isStarted()) { + return -1; + } + + final long startedDate = questProgress.getStartedDate(); + if (startedDate == 0) { + return -1; } - return -1; + + final long currentTimeMillis = System.currentTimeMillis(); + final long timeLimitMillis = TimeUnit.MILLISECONDS.convert(quest.getTimeLimit(), TimeUnit.MINUTES); + + // do the subtraction first to prevent overflow + return Math.max(0L, startedDate - currentTimeMillis + timeLimitMillis); } /** - * Tests whether or not the player meets the requirements to start a specific quest. + * Tests whether the player meets the requirements to start a specific quest. * * @param quest the quest to test for * @return true if they can start the quest */ - //TODO possibly move this - public boolean hasMetRequirements(Quest quest) { - for (String id : quest.getRequirements()) { - Quest q = plugin.getQuestManager().getQuestById(id); - if (q == null) { - continue; - } - if (hasQuestProgress(q) && !getQuestProgress(q).isCompletedBefore()) { + // TODO possibly move this + @Contract(pure = true) + public boolean hasMetRequirements(final @NotNull Quest quest) { + for (final String requiredQuestId : quest.getRequirements()) { + final QuestProgress requiredQuestProgress = this.questProgressMap.get(requiredQuestId); + if (requiredQuestProgress == null || !requiredQuestProgress.isCompletedBefore()) { + // if we decide to change the method return type to states like "DOES_NOT_EXIST" + // or "COMPLETED_BEFORE" we will need to change the quest existance check order return false; - } else if (!hasQuestProgress(q)) { + } + + final Quest requiredQuest = this.plugin.getQuestManager().getQuestById(requiredQuestId); + if (requiredQuest == null) { + // TODO not sure if we actually need this check however probably + // forcing the server owner to fix the quest options instead + // of just ignoring the fact it's broken is better? return false; } } + return true; } /** - * Get the {@link UUID} of the player this QuestProgressFile represents. - * - * @return UUID + * @return the associated player UUID */ - public UUID getPlayerUUID() { - return playerUUID; + @Contract(pure = true) + public @NotNull UUID getPlayerUUID() { + return this.playerUUID; } /** - * Get the {@link QuestProgress} for a specified {@link Quest}. Generates a new one if it does not exist. + * Gets the {@link QuestProgress} for a specified {@link Quest}. Generates a new one if it does not exist. * - * @param quest the quest to get progress for + * @param quest the quest to get the progress for * @return {@link QuestProgress} or a blank generated one if the quest does not exist */ - public QuestProgress getQuestProgress(Quest quest) { - QuestProgress qProgress = questProgress.get(quest.getId()); - return qProgress != null ? qProgress : generateBlankQuestProgress(quest); + public @NotNull QuestProgress getQuestProgress(final @NotNull Quest quest) { + final QuestProgress questProgress = this.getQuestProgressOrNull(quest); + return questProgress != null ? questProgress : this.generateBlankQuestProgress(quest); } /** - * Tests whether or not the player has a specified {@link Quest} started. + * Gets the {@link QuestProgress} for a specified {@link Quest}. Returns null if it does not exist. + * + * @param quest the quest to get the progress for + * @return {@link QuestProgress} or null if the quest does not exist + */ + @Contract(pure = true) + public @Nullable QuestProgress getQuestProgressOrNull(final @NotNull Quest quest) { + return this.questProgressMap.get(quest.getId()); + } + + /** + * Tests whether the player has a specified {@link Quest} started. * * @param quest the quest to check for * @return true if player has the quest started */ - public boolean hasQuestStarted(Quest quest) { - QuestProgress qProgress = questProgress.get(quest.getId()); - return qProgress != null && qProgress.isStarted(); + @Contract(pure = true) + public boolean hasQuestStarted(final @NotNull Quest quest) { + final QuestProgress questProgress = this.getQuestProgressOrNull(quest); + return questProgress != null && questProgress.isStarted(); } /** - * Generate a new blank {@link QuestProgress} for a specified {@code quest}. + * Generate a new blank {@link QuestProgress} for a specified {@link Quest} with {@link QuestProgress#isModified()} set to {@code false}. * - * @param quest the quest to generate progress for + * @param quest the quest to generate the progress for * @return the generated blank {@link QuestProgress} */ - public QuestProgress generateBlankQuestProgress(Quest quest) { - return generateBlankQuestProgress(quest, false); + public @NotNull QuestProgress generateBlankQuestProgress(final @NotNull Quest quest) { + return this.generateBlankQuestProgress(quest, false); } /** - * Generate a new blank {@link QuestProgress} for a specified {@code quest}. + * Generate a new blank {@link QuestProgress} for a specified {@link Quest}. * - * @param quest the quest to generate progress for + * @param quest the quest to generate the progress for * @param modified the modified state of the quest * @return the generated blank {@link QuestProgress} */ - public QuestProgress generateBlankQuestProgress(Quest quest, boolean modified) { - QuestProgress questProgress = new QuestProgress(plugin, quest.getId(), false, false, 0, playerUUID, false, 0, modified); - for (Task task : quest.getTasks()) { - TaskProgress taskProgress = new TaskProgress(questProgress, task.getId(), null, playerUUID, false, modified); + public @NotNull QuestProgress generateBlankQuestProgress(final @NotNull Quest quest, final boolean modified) { + final QuestProgress questProgress = new QuestProgress(this.plugin, quest.getId(), this.playerUUID, false, 0L, false, false, 0L, modified); + + for (final Task task : quest.getTasks()) { + final TaskProgress taskProgress = new TaskProgress(questProgress, task.getId(), this.playerUUID, null, false, modified); questProgress.addTaskProgress(taskProgress); } - addQuestProgress(questProgress); + this.addQuestProgress(questProgress); return questProgress; } + /** + * Clears quest progress map. + */ public void clear() { - questProgress.clear(); + this.questProgressMap.clear(); } /** @@ -261,15 +363,17 @@ public void clear() { * set the modified flag in that case. */ public void reset() { - for (QuestProgress questProgress : questProgress.values()) { + for (final QuestProgress questProgress : this.questProgressMap.values()) { if (!questProgress.hasNonDefaultValues()) { continue; } - Quest quest = plugin.getQuestManager().getQuestById(questProgress.getQuestId()); + + final Quest quest = this.plugin.getQuestManager().getQuestById(questProgress.getQuestId()); if (quest == null) { continue; } - generateBlankQuestProgress(quest, true); + + this.generateBlankQuestProgress(quest, true); } } @@ -278,41 +382,61 @@ public void reset() { */ @Deprecated public void clean() { - plugin.getQuestsLogger().debug("Cleaning file " + playerUUID + "."); - if (!plugin.getTaskTypeManager().areRegistrationsOpen()) { - ArrayList invalidQuests = new ArrayList<>(); - for (String questId : this.questProgress.keySet()) { - Quest q; - if ((q = plugin.getQuestManager().getQuestById(questId)) == null) { - invalidQuests.add(questId); - } else { - ArrayList invalidTasks = new ArrayList<>(); - for (String taskId : this.questProgress.get(questId).getTaskProgressMap().keySet()) { - if (q.getTaskById(taskId) == null) { - invalidTasks.add(taskId); - } - } - for (String taskId : invalidTasks) { - this.questProgress.get(questId).getTaskProgressMap().remove(taskId); + this.plugin.getQuestsLogger().debug("Cleaning file " + this.playerUUID + "."); + + if (!this.plugin.getTaskTypeManager().areRegistrationsOpen()) { + final List invalidQuestIds = new ArrayList<>(); + + for (final Map.Entry questProgressEntry : this.questProgressMap.entrySet()) { + final String questId = questProgressEntry.getKey(); + + final Quest quest = this.plugin.getQuestManager().getQuestById(questId); + if (quest == null) { + invalidQuestIds.add(questId); + + // tasks will be removed with the quest + continue; + } + + final QuestProgress questProgress = questProgressEntry.getValue(); + final Map taskProgressMap = questProgress.getTaskProgressMap(); + final List invalidTaskIds = new ArrayList<>(); + + for (final String taskId : taskProgressMap.keySet()) { + final Task task = quest.getTaskById(taskId); + + if (task == null) { + invalidTaskIds.add(taskId); } } + + for (final String taskId : invalidTaskIds) { + taskProgressMap.remove(taskId); + } } - for (String questId : invalidQuests) { - this.questProgress.remove(questId); + + for (final String questId : invalidQuestIds) { + this.questProgressMap.remove(questId); } } } + /** + * It's equivalent to {@code QuestProgressFile#setModified(false)}. + * + * @see QuestProgressFile#setModified(boolean) + */ + @Deprecated(forRemoval = true) public void resetModified() { - for (QuestProgress questProgress : questProgress.values()) { - questProgress.resetModified(); - } + this.setModified(false); } - public void setModified(boolean modified) { - for (QuestProgress questProgress : questProgress.values()) { + /** + * @param modified whether the object has been modified and needs to be saved + */ + public void setModified(final boolean modified) { + for (final QuestProgress questProgress : this.questProgressMap.values()) { questProgress.setModified(modified); } } - } diff --git a/common/src/main/java/com/leonardobishop/quests/common/player/questprogressfile/TaskProgress.java b/common/src/main/java/com/leonardobishop/quests/common/player/questprogressfile/TaskProgress.java index a78a4d25c..9395634eb 100644 --- a/common/src/main/java/com/leonardobishop/quests/common/player/questprogressfile/TaskProgress.java +++ b/common/src/main/java/com/leonardobishop/quests/common/player/questprogressfile/TaskProgress.java @@ -1,84 +1,163 @@ package com.leonardobishop.quests.common.player.questprogressfile; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; import java.util.UUID; -public class TaskProgress { +public final class TaskProgress { - private final String taskid; - private final UUID player; + private final QuestProgress questProgress; + private final String taskId; + private final UUID playerUUID; - private QuestProgress linkedQuestProgress; - private boolean modified; private Object progress; private boolean completed; + private boolean modified; - public TaskProgress(QuestProgress linkedQuestProgress, String taskid, Object progress, UUID player, boolean completed) { - this.linkedQuestProgress = linkedQuestProgress; - this.taskid = taskid; + /** + * Constructs a TaskProgress. + * + * @param questProgress the quest progress + * @param taskId the associated task ID + * @param playerUUID the associated player UUID + * @param progress the progress object + * @param completed whether the task is completed + * @param modified whether the object has been modified and needs to be saved + */ + public TaskProgress(final @Nullable QuestProgress questProgress, final @NotNull String taskId, final @NotNull UUID playerUUID, final @Nullable Object progress, final boolean completed, final boolean modified) { + this.questProgress = questProgress; + this.taskId = taskId; + this.playerUUID = playerUUID; this.progress = progress; - this.player = player; this.completed = completed; + this.modified = modified; } - public TaskProgress(QuestProgress linkedQuestProgress, String taskid, Object progress, UUID player, boolean completed, boolean modified) { - this(linkedQuestProgress, taskid, progress, player, completed); - this.modified = modified; + /** + * Constructs a TaskProgress with {@link TaskProgress#modified} set to {@code false}. + * + * @param questProgress the quest progress + * @param taskId the associated task ID + * @param playerUUID the associated player UUID + * @param progress the progress object + * @param completed whether the task is completed + */ + public TaskProgress(final @NotNull QuestProgress questProgress, final @NotNull String taskId, final @NotNull UUID playerUUID, final @Nullable Object progress, final boolean completed) { + this(questProgress, taskId, playerUUID, progress, completed, false); } - public TaskProgress(TaskProgress taskProgress) { - this.taskid = taskProgress.taskid; - this.player = taskProgress.player; - this.modified = taskProgress.modified; - this.progress = taskProgress.progress; - this.completed = taskProgress.completed; + /** + * Constructs a data-only clone from a TaskProgress instance. + * + * @param taskProgress the task progress instance + */ + @ApiStatus.Internal + public TaskProgress(final @NotNull TaskProgress taskProgress) { + this(null, taskProgress.taskId, taskProgress.playerUUID, taskProgress.progress, taskProgress.completed, taskProgress.modified); } - public String getTaskId() { - return taskid; + /** + * @return the associated task ID + */ + @Contract(pure = true) + public @NotNull String getTaskId() { + return this.taskId; } - public Object getProgress() { - return progress; + /** + * @return the associated player ID + * @see QuestProgress#getPlayerUUID() + */ + @Deprecated(forRemoval = true) + @Contract(pure = true) + public @NotNull UUID getPlayer() { + return this.playerUUID; } - public void setProgress(Object progress) { - if (this.progress != progress) this.modified = true; + /** + * @return the associated player ID + */ + @Contract(pure = true) + public @NotNull UUID getPlayerUUID() { + return this.playerUUID; + } - this.progress = progress; + /** + * @return the progress object + */ + @Contract(pure = true) + public @Nullable Object getProgress() { + return this.progress; } - public UUID getPlayer() { - return player; + /** + * @param progress the progress object + */ + public void setProgress(final @Nullable Object progress) { + if (Objects.equals(progress, this.progress)) { + return; + } + + this.progress = progress; + this.modified = true; } + /** + * @return whether the task is completed + */ + @Contract(pure = true) public boolean isCompleted() { - return completed; + return this.completed; } - public void setCompleted(boolean complete) { + /** + * @param completed whether the task is completed + */ + public void setCompleted(final boolean completed) { + if (this.questProgress == null) { + throw new UnsupportedOperationException("associated quest progress cannot be null"); + } + // do not queue completion for already completed quests // https://github.com/LMBishop/Quests/issues/543 - if (this.completed == complete) { + if (this.completed == completed) { return; } - this.completed = complete; + this.completed = completed; this.modified = true; - if (complete) { - linkedQuestProgress.queueForCompletionTest(); + if (completed) { + this.questProgress.queueForCompletionTest(); } } + /** + * @return whether the object has been modified and needs to be saved + */ + @Contract(pure = true) public boolean isModified() { - return modified; + return this.modified; } + /** + * It's equivalent to {@code TaskProgress#setModified(false)}. + * + * @see TaskProgress#setModified(boolean) + */ + @Deprecated(forRemoval = true) public void resetModified() { - this.modified = false; + this.setModified(false); } - public void setModified(boolean modified) { + /** + * @param modified whether the object has been modified and needs to be saved + */ + public void setModified(final boolean modified) { this.modified = modified; } } diff --git a/common/src/main/java/com/leonardobishop/quests/common/storage/StorageProvider.java b/common/src/main/java/com/leonardobishop/quests/common/storage/StorageProvider.java index ba0ee8c88..a9c7b52fd 100644 --- a/common/src/main/java/com/leonardobishop/quests/common/storage/StorageProvider.java +++ b/common/src/main/java/com/leonardobishop/quests/common/storage/StorageProvider.java @@ -1,61 +1,91 @@ package com.leonardobishop.quests.common.storage; -import com.leonardobishop.quests.common.player.questprogressfile.QuestProgressFile; +import com.leonardobishop.quests.common.player.QPlayerData; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.io.IOException; import java.util.List; +import java.util.Objects; import java.util.UUID; /** - * The storage provider is responsible for obtaining a QuestProgressFile for a specified UUID and for - * writing a QuestProgressFile. + * The StorageProvider interface defines the contract for a storage system that handles the persistence + * of player data, such as player preferences and quest progress, for specific players identified by their UUIDs. + * Implementations of this interface are responsible for the actual storage and retrieval of this data. */ public interface StorageProvider { - String getName(); + /** + * Retrieves the name of this storage provider. + * + * @return the name of the storage provider + */ + @NotNull String getName(); - void init(); + /** + * Initializes the storage provider, preparing it for use. This method should be called before any + * other operations are performed. Initialization may involve setting up connections or loading necessary resources. + */ + void init() throws IOException; + /** + * Shuts down the storage provider, ensuring that any open resources are properly closed and that + * any pending data is safely stored. This method should be called during the application's shutdown process. + */ void shutdown(); /** - * Load a QuestProgressFile from the data source by a specific UUID + * Loads the player data associated with the given UUID from the storage. * - * @param uuid the UUID to load - * @return {@link QuestProgressFile} or null + * @param uuid the unique identifier of the player whose data is to be loaded + * @return the {@link QPlayerData} for the player, or null if no data is found for the given UUID */ - @Nullable QuestProgressFile loadProgressFile(@NotNull UUID uuid); + @Nullable QPlayerData loadPlayerData(final @NotNull UUID uuid); /** - * Save a QuestProgressFile to the data source with a specific UUID + * Saves the given player data to the storage. * - * @param uuid the uuid to match the file to - * @param questProgressFile the file to save + * @param playerData the {@link QPlayerData} object containing the player's data to be saved + * @return true if the data was successfully saved, false otherwise */ - boolean saveProgressFile(@NotNull UUID uuid, @NotNull QuestProgressFile questProgressFile); + boolean savePlayerData(final @NotNull QPlayerData playerData); /** - * Load all QuestProgressFiles + * Loads all player data available in the storage. * - * @return {@link List} + * @return a list of {@link QPlayerData} objects */ - @NotNull List loadAllProgressFiles(); + @NotNull List loadAllPlayerData(); /** - * Save a list of QuestProgressFiles + * Saves all provided player data to the storage. * - * @param files the list of QuestProgressFile to save - **/ - void saveAllProgressFiles(List files); + * @param allPlayerData a list of {@link QPlayerData} objects to be saved + * @return true if the data was successfully saved, false otherwise + */ + default boolean saveAllPlayerData(final @NotNull List allPlayerData) { + Objects.requireNonNull(allPlayerData, "allPlayerData cannot be null"); + + // fault check is not needed here as the method + // saving single player data already handles that, + // and it's actually the one we need to check + + boolean result = true; + + for (final QPlayerData playerData : allPlayerData) { + result &= this.savePlayerData(playerData); + } + + return result; + } /** - * Whether this provider is 'similar' to another one. - * Similarity is determined if the provider effectively points to the same data source. + * Compares this storage provider with another to determine if they are similar. + * Similarity is determined by effectively pointing to the same data source. * - * @param provider the provider to compare to - * @return true if similar, false otherwise + * @param otherProvider another StorageProvider to compare against + * @return true if the two storage providers are considered similar, false otherwise */ - boolean isSimilar(StorageProvider provider); - + boolean isSimilar(final @NotNull StorageProvider otherProvider); }