From fbb82ac8544e62bd6236842de0aac8ad855e4ded Mon Sep 17 00:00:00 2001 From: berrytern Date: Tue, 18 Aug 2026 05:43:36 +0200 Subject: [PATCH 1/8] feat(core): add database player concurrency lock to prevent web/game race conditions --- config.lua.dist | 6 ++++++ schema.sql | 4 ++++ src/config/config_enums.hpp | 2 ++ src/config/configmanager.cpp | 2 ++ src/io/functions/iologindata_load_player.cpp | 13 ++++++++++++- 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/config.lua.dist b/config.lua.dist index 9bf7e087d..8f0ec2365 100644 --- a/config.lua.dist +++ b/config.lua.dist @@ -92,6 +92,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/schema.sql b/schema.sql index ff340045c..a9ceb0004 100644 --- a/schema.sql +++ b/schema.sql @@ -156,8 +156,12 @@ CREATE TABLE IF NOT EXISTS `players` ( `virtue` int(10) UNSIGNED NOT NULL DEFAULT '0', `harmony` int(10) UNSIGNED NOT NULL DEFAULT '0', `weapon_proficiencies` mediumblob DEFAULT NULL, + `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 224b00a9a..b6d8e01fc 100644 --- a/src/config/config_enums.hpp +++ b/src/config/config_enums.hpp @@ -243,6 +243,7 @@ enum ConfigKey_t : uint16_t { OWNER_EMAIL, OWNER_NAME, PARALYZE_DELAY_INTERVAL, + PLAYER_LOCK_TIMEOUT, PARALLELISM, PARTY_AUTO_SHARE_EXPERIENCE, PARTY_LIST_MAX_DISTANCE, @@ -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 4ea2fe5ad..213f2461d 100644 --- a/src/config/configmanager.cpp +++ b/src/config/configmanager.cpp @@ -183,6 +183,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_CLEAN_MAP, "toggleSaveIntervalCleanMap", false); @@ -362,6 +363,7 @@ bool ConfigManager::load() { loadIntConfig(L, MONTH_KILLS_TO_RED, "monthKillsToRedSkull", 10); loadIntConfig(L, ORANGE_SKULL_DURATION, "orangeSkullDuration", 7); loadIntConfig(L, PARALLELISM, "parallelism", 2); + loadIntConfig(L, PLAYER_LOCK_TIMEOUT, "playerLockTimeout", 60); loadIntConfig(L, PARTY_LIST_MAX_DISTANCE, "partyListMaxDistance", 0); 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 c7219567f..579619159 100644 --- a/src/io/functions/iologindata_load_player.cpp +++ b/src/io/functions/iologindata_load_player.cpp @@ -85,7 +85,7 @@ bool IOLoginDataLoad::preLoadPlayer(const std::shared_ptr &player, const Database &db = Database::getInstance(); std::ostringstream query; - query << "SELECT `id`, `account_id`, `group_id`, `deletion` FROM `players` WHERE `name` = " << db.escapeString(name); + query << "SELECT `id`, `account_id`, `group_id`, `deletion`, `is_locked`, `locked_at`, `lock_reason` FROM `players` WHERE `name` = " << db.escapeString(name); DBResult_ptr result = db.storeQuery(query.str()); if (!result) { return false; @@ -95,6 +95,17 @@ 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 timeout = g_configManager().getNumber(PLAYER_LOCK_TIMEOUT); + if (time(nullptr) - 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->setGUID(result->getNumber("id")); const auto &group = g_game().groups.getGroup(result->getNumber("group_id")); if (!group) { From fd68403b1ab0c337d50cdd9ef50ff48476b055d5 Mon Sep 17 00:00:00 2001 From: berrytern Date: Tue, 18 Aug 2026 17:03:35 +0200 Subject: [PATCH 2/8] fix(core): improve lock expiration with db timestamp and fix config sorting --- src/config/config_enums.hpp | 2 +- src/config/configmanager.cpp | 2 +- src/io/functions/iologindata_load_player.cpp | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/config/config_enums.hpp b/src/config/config_enums.hpp index b6d8e01fc..365653bbb 100644 --- a/src/config/config_enums.hpp +++ b/src/config/config_enums.hpp @@ -243,13 +243,13 @@ enum ConfigKey_t : uint16_t { OWNER_EMAIL, OWNER_NAME, PARALYZE_DELAY_INTERVAL, - PLAYER_LOCK_TIMEOUT, PARALLELISM, PARTY_AUTO_SHARE_EXPERIENCE, PARTY_LIST_MAX_DISTANCE, 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, diff --git a/src/config/configmanager.cpp b/src/config/configmanager.cpp index 213f2461d..568da817f 100644 --- a/src/config/configmanager.cpp +++ b/src/config/configmanager.cpp @@ -363,8 +363,8 @@ bool ConfigManager::load() { loadIntConfig(L, MONTH_KILLS_TO_RED, "monthKillsToRedSkull", 10); loadIntConfig(L, ORANGE_SKULL_DURATION, "orangeSkullDuration", 7); loadIntConfig(L, PARALLELISM, "parallelism", 2); - loadIntConfig(L, PLAYER_LOCK_TIMEOUT, "playerLockTimeout", 60); loadIntConfig(L, PARTY_LIST_MAX_DISTANCE, "partyListMaxDistance", 0); + loadIntConfig(L, PLAYER_LOCK_TIMEOUT, "playerLockTimeout", 60); loadIntConfig(L, PREY_BONUS_REROLL_PRICE, "preyBonusRerollPrice", 1); loadIntConfig(L, PREY_BONUS_TIME, "preyBonusTime", 7200); loadIntConfig(L, PREY_FREE_REROLL_TIME, "preyFreeRerollTime", 72000); diff --git a/src/io/functions/iologindata_load_player.cpp b/src/io/functions/iologindata_load_player.cpp index 579619159..440a27313 100644 --- a/src/io/functions/iologindata_load_player.cpp +++ b/src/io/functions/iologindata_load_player.cpp @@ -85,7 +85,7 @@ bool IOLoginDataLoad::preLoadPlayer(const std::shared_ptr &player, const Database &db = Database::getInstance(); std::ostringstream query; - query << "SELECT `id`, `account_id`, `group_id`, `deletion`, `is_locked`, `locked_at`, `lock_reason` FROM `players` WHERE `name` = " << db.escapeString(name); + query << "SELECT `id`, `account_id`, `group_id`, `deletion`, `is_locked`, `locked_at`, `lock_reason`, UNIX_TIMESTAMP() AS `db_time` FROM `players` WHERE `name` = " << db.escapeString(name); DBResult_ptr result = db.storeQuery(query.str()); if (!result) { return false; @@ -98,8 +98,9 @@ bool IOLoginDataLoad::preLoadPlayer(const std::shared_ptr &player, const if (g_configManager().getBoolean(TOGGLE_PLAYER_LOCK)) { if (result->getNumber("is_locked") == 1) { auto lockedAt = result->getNumber("locked_at"); - auto timeout = g_configManager().getNumber(PLAYER_LOCK_TIMEOUT); - if (time(nullptr) - lockedAt < timeout) { + auto dbTime = result->getNumber("db_time"); + auto timeout = std::max(1, g_configManager().getNumber(PLAYER_LOCK_TIMEOUT)); + if (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; } From 5ea9b4bd4ce043a0b5361677961958b9e9d1723d Mon Sep 17 00:00:00 2001 From: berrytern Date: Wed, 19 Aug 2026 04:33:17 +0200 Subject: [PATCH 3/8] feat(schema): add migration 64 for player concurrency lock columns and index --- data/migrations/64.lua | 43 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 data/migrations/64.lua diff --git a/data/migrations/64.lua b/data/migrations/64.lua new file mode 100644 index 000000000..7afc89441 --- /dev/null +++ b/data/migrations/64.lua @@ -0,0 +1,43 @@ +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 64 (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 From 964705d4e46241d5ce05a1d4f7f34efe25cb2225 Mon Sep 17 00:00:00 2001 From: berrytern Date: Wed, 19 Aug 2026 05:02:28 +0200 Subject: [PATCH 4/8] fix(core): use int64_t for lock timestamps and bound future timestamp window --- src/io/functions/iologindata_load_player.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/io/functions/iologindata_load_player.cpp b/src/io/functions/iologindata_load_player.cpp index 440a27313..c105147cc 100644 --- a/src/io/functions/iologindata_load_player.cpp +++ b/src/io/functions/iologindata_load_player.cpp @@ -97,10 +97,10 @@ bool IOLoginDataLoad::preLoadPlayer(const std::shared_ptr &player, const 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 lockedAt = result->getNumber("locked_at"); + auto dbTime = result->getNumber("db_time"); auto timeout = std::max(1, g_configManager().getNumber(PLAYER_LOCK_TIMEOUT)); - if (dbTime - lockedAt < 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; } From a9480ed80276737d1f68c53c91d4690d1fa0bee6 Mon Sep 17 00:00:00 2001 From: berrytern Date: Wed, 19 Aug 2026 18:32:42 +0200 Subject: [PATCH 5/8] style(migrations): format migration 64 with StyLua --- data/migrations/64.lua | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/data/migrations/64.lua b/data/migrations/64.lua index 7afc89441..2f4b98f1e 100644 --- a/data/migrations/64.lua +++ b/data/migrations/64.lua @@ -1,8 +1,5 @@ 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 - )) + 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 @@ -11,10 +8,7 @@ local function columnExists(tableName, columnName) 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 - )) + 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 From 960d43dc85af42efc4f122c032ee6e368980e94b Mon Sep 17 00:00:00 2001 From: berrytern Date: Wed, 19 Aug 2026 18:40:21 +0200 Subject: [PATCH 6/8] chore(schema): update db_version to 64 in schema.sql --- schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema.sql b/schema.sql index a9ceb0004..73035ccfb 100644 --- a/schema.sql +++ b/schema.sql @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS `server_config` ( CONSTRAINT `server_config_pk` PRIMARY KEY (`config`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; -INSERT INTO `server_config` (`config`, `value`) VALUES ('db_version', '63'), ('motd_hash', ''), ('motd_num', '0'), ('players_record', '0'); +INSERT INTO `server_config` (`config`, `value`) VALUES ('db_version', '64'), ('motd_hash', ''), ('motd_num', '0'), ('players_record', '0'); -- Table structure `accounts` CREATE TABLE IF NOT EXISTS `accounts` ( From 7374d4eb66837e3c503fce701aab4c35a62f9c86 Mon Sep 17 00:00:00 2001 From: berrytern Date: Fri, 21 Aug 2026 06:42:31 +0200 Subject: [PATCH 7/8] ci: trigger workflow re-run From 26301c7cf0791ef0633f8afdb86f6df62a3f9506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo?= Date: Wed, 16 Sep 2026 08:48:16 -0300 Subject: [PATCH 8/8] Create 66.lua --- data/migrations/66.lua | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 data/migrations/66.lua 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