diff --git a/config.lua.dist b/config.lua.dist index 6b5094cd9..a724d19c3 100644 --- a/config.lua.dist +++ b/config.lua.dist @@ -98,6 +98,12 @@ maxPlayersOutsidePZPerAccount = 1 toggleMaxConnectionsByIP = false maxIPConnections = 3 +-- Player Concurrency Lock +-- NOTE: togglePlayerLock blocks character logins during web transactions (marketplace, admin changes, character bazaar) +-- NOTE: playerLockTimeout defines the lock expiry in seconds to prevent deadlock if a web request fails +togglePlayerLock = true +playerLockTimeout = 60 + -- Packet Compression -- Minimize network bandwith and reduce ping -- Levels: 0 = disabled, 1 = best speed, 9 = best compression diff --git a/data/migrations/66.lua b/data/migrations/66.lua new file mode 100644 index 000000000..c3de9c00e --- /dev/null +++ b/data/migrations/66.lua @@ -0,0 +1,37 @@ +local function columnExists(tableName, columnName) + local resultId = db.storeQuery(string.format("SELECT 1 FROM `information_schema`.`COLUMNS` WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = '%s' AND `COLUMN_NAME` = '%s' LIMIT 1;", tableName, columnName)) + if resultId then + Result.free(resultId) + return true + end + return false +end + +local function indexExists(tableName, indexName) + local resultId = db.storeQuery(string.format("SELECT 1 FROM `information_schema`.`STATISTICS` WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = '%s' AND `INDEX_NAME` = '%s' LIMIT 1;", tableName, indexName)) + if resultId then + Result.free(resultId) + return true + end + return false +end + +function onUpdateDatabase() + logger.info("Updating database to version 66 (feat: player concurrency lock columns and index)") + + if not columnExists("players", "is_locked") then + db.query("ALTER TABLE `players` ADD COLUMN `is_locked` tinyint(1) NOT NULL DEFAULT '0';") + end + + if not columnExists("players", "locked_at") then + db.query("ALTER TABLE `players` ADD COLUMN `locked_at` bigint(20) NOT NULL DEFAULT '0';") + end + + if not columnExists("players", "lock_reason") then + db.query("ALTER TABLE `players` ADD COLUMN `lock_reason` varchar(64) NOT NULL DEFAULT '';") + end + + if not indexExists("players", "idx_players_concurrency_lock") then + db.query("ALTER TABLE `players` ADD INDEX `idx_players_concurrency_lock` (`is_locked`, `locked_at`);") + end +end diff --git a/schema.sql b/schema.sql index 0dee3071e..85ca1ab88 100644 --- a/schema.sql +++ b/schema.sql @@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS `server_config` ( ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -INSERT INTO `server_config` (`config`, `value`, `world_id`) VALUES ('db_version', '65', 1), ('motd_hash', '', 1), ('motd_num', '0', 1), ('players_record', '0', 1); +INSERT INTO `server_config` (`config`, `value`, `world_id`) VALUES ('db_version', '66', 1), ('motd_hash', '', 1), ('motd_num', '0', 1), ('players_record', '0', 1); -- Table structure `accounts` CREATE TABLE IF NOT EXISTS `accounts` ( @@ -180,8 +180,12 @@ CREATE TABLE IF NOT EXISTS `players` ( `harmony` int(10) UNSIGNED NOT NULL DEFAULT '0', `weapon_proficiencies` mediumblob DEFAULT NULL, `charbazaar` tinyint(1) NOT NULL DEFAULT '0', + `is_locked` tinyint(1) NOT NULL DEFAULT '0', + `locked_at` bigint(20) NOT NULL DEFAULT '0', + `lock_reason` varchar(64) NOT NULL DEFAULT '', INDEX `account_id` (`account_id`), INDEX `vocation` (`vocation`), + INDEX `idx_players_concurrency_lock` (`is_locked`, `locked_at`), CONSTRAINT `players_pk` PRIMARY KEY (`id`), CONSTRAINT `players_unique` UNIQUE (`name`), CONSTRAINT `players_account_fk` diff --git a/src/config/config_enums.hpp b/src/config/config_enums.hpp index 298c71f0f..d7003c503 100644 --- a/src/config/config_enums.hpp +++ b/src/config/config_enums.hpp @@ -249,6 +249,7 @@ enum ConfigKey_t : uint16_t { PARTY_SHARE_LOOT_BOOSTS, PARTY_SHARE_LOOT_BOOSTS_DIMINISHING_FACTOR, PARTY_SHARE_RANGE_MULTIPLIER, + PLAYER_LOCK_TIMEOUT, PREMIUM_DEPOT_LIMIT, PREY_BONUS_REROLL_PRICE, PREY_BONUS_TIME, @@ -366,6 +367,7 @@ enum ConfigKey_t : uint16_t { TOGGLE_MAP_CUSTOM, TOGGLE_MAX_CONNECTIONS_BY_IP, TOGGLE_MOUNT_IN_PZ, + TOGGLE_PLAYER_LOCK, TOGGLE_RECEIVE_REWARD, TOGGLE_SAVE_ASYNC, TOGGLE_SAVE_INTERVAL, diff --git a/src/config/configmanager.cpp b/src/config/configmanager.cpp index 8767c10f7..d0d32d9cb 100644 --- a/src/config/configmanager.cpp +++ b/src/config/configmanager.cpp @@ -179,6 +179,7 @@ bool ConfigManager::load() { loadBoolConfig(L, TOGGLE_IMBUEMENT_SHRINE_STORAGE, "toggleImbuementShrineStorage", true); loadBoolConfig(L, TOGGLE_MAX_CONNECTIONS_BY_IP, "toggleMaxConnectionsByIP", false); loadBoolConfig(L, TOGGLE_MOUNT_IN_PZ, "toggleMountInProtectionZone", false); + loadBoolConfig(L, TOGGLE_PLAYER_LOCK, "togglePlayerLock", true); loadBoolConfig(L, TOGGLE_RECEIVE_REWARD, "toggleReceiveReward", false); loadBoolConfig(L, TOGGLE_SAVE_ASYNC, "toggleSaveAsync", false); loadBoolConfig(L, TOGGLE_SAVE_INTERVAL, "toggleSaveInterval", false); @@ -369,6 +370,7 @@ bool ConfigManager::load() { loadIntConfig(L, PARALYZE_DELAY_INTERVAL, "timeAfterParalyze", 200); loadIntConfig(L, PARALLELISM, "parallelism", 2); loadIntConfig(L, PARTY_LIST_MAX_DISTANCE, "partyListMaxDistance", 0); + loadIntConfig(L, PLAYER_LOCK_TIMEOUT, "playerLockTimeout", 60); loadIntConfig(L, PREMIUM_DEPOT_LIMIT, "premiumDepotLimit", 8000); loadIntConfig(L, PREY_BONUS_REROLL_PRICE, "preyBonusRerollPrice", 1); loadIntConfig(L, PREY_BONUS_TIME, "preyBonusTime", 7200); diff --git a/src/io/functions/iologindata_load_player.cpp b/src/io/functions/iologindata_load_player.cpp index 1f0313c84..2ff19950d 100644 --- a/src/io/functions/iologindata_load_player.cpp +++ b/src/io/functions/iologindata_load_player.cpp @@ -84,7 +84,7 @@ void IOLoginDataLoad::loadItems(ItemsMap &itemsMap, const DBResult_ptr &result, bool IOLoginDataLoad::preLoadPlayer(const std::shared_ptr &player, const std::string &name) { Database &db = Database::getInstance(); - std::string query = fmt::format("SELECT `id`, `account_id`, `group_id`, `deletion`, `world_id` FROM `players` WHERE `name` = {}", db.escapeString(name)); + std::string query = fmt::format("SELECT `id`, `account_id`, `group_id`, `deletion`, `world_id`, `is_locked`, `locked_at`, `lock_reason`, UNIX_TIMESTAMP() AS `db_time` FROM `players` WHERE `name` = {}", db.escapeString(name)); DBResult_ptr result = db.storeQuery(query); if (!result) { return false; @@ -94,6 +94,18 @@ bool IOLoginDataLoad::preLoadPlayer(const std::shared_ptr &player, const return false; } + if (g_configManager().getBoolean(TOGGLE_PLAYER_LOCK)) { + if (result->getNumber("is_locked") == 1) { + auto lockedAt = result->getNumber("locked_at"); + auto dbTime = result->getNumber("db_time"); + auto timeout = std::max(1, g_configManager().getNumber(PLAYER_LOCK_TIMEOUT)); + if (lockedAt > 0 && lockedAt <= dbTime + timeout && (dbTime - lockedAt < timeout)) { + g_logger().warn("Player {} login rejected: character is currently locked for a web/market transaction (reason: {})", name, result->getString("lock_reason")); + return false; + } + } + } + player->worldId = result->getNumber("world_id"); player->setGUID(result->getNumber("id"));