diff --git a/data/sql/updates/pending_db_world/rev_lfg_cooldown_command.sql b/data/sql/updates/pending_db_world/rev_lfg_cooldown_command.sql new file mode 100644 index 000000000..84a9f28db --- /dev/null +++ b/data/sql/updates/pending_db_world/rev_lfg_cooldown_command.sql @@ -0,0 +1,9 @@ +-- Add lfg cooldown command to the command table +DELETE FROM `command` WHERE `name` IN ('lfg cooldown'); +INSERT INTO `command` (`name`, `security`, `help`) VALUES +('lfg cooldown', 3, 'Syntax: .lfg cooldown\nClears all LFG dungeon cooldowns for all players.'); + +-- Add acore_string for cooldown cleared message (English, German, Spanish) +DELETE FROM `acore_string` WHERE `entry` = 11019; +INSERT INTO `acore_string` (`entry`, `content_default`, `locale_koKR`, `locale_frFR`, `locale_deDE`, `locale_zhCN`, `locale_zhTW`, `locale_esES`, `locale_esMX`, `locale_ruRU`) VALUES +(11019, 'LFG dungeon cooldowns cleared for all players.', NULL, NULL, 'LFG-Dungeon-Abklingzeiten für alle Spieler zurückgesetzt.', NULL, NULL, 'Tiempos de reutilización de mazmorras LFG eliminados para todos los jugadores.', 'Tiempos de reutilización de mazmorras LFG eliminados para todos los jugadores.', NULL); diff --git a/src/server/apps/worldserver/worldserver.conf.dist b/src/server/apps/worldserver/worldserver.conf.dist index 3bb2c4b85..44da43222 100644 --- a/src/server/apps/worldserver/worldserver.conf.dist +++ b/src/server/apps/worldserver/worldserver.conf.dist @@ -3404,6 +3404,16 @@ DungeonFinder.CastDeserter = 1 DungeonFinder.AllowCompleted = 1 +# +# DungeonFinder.DungeonSelectionCooldown +# +# Description: Duration in minutes for the dungeon selection cooldown. Players who complete a +# random dungeon via LFG will not be assigned the same dungeon again for this +# duration. This reduces the chance of getting the same dungeon in a row. +# Default: 0 - (Disabled) + +DungeonFinder.DungeonSelectionCooldown = 0 + # ################################################################################################### diff --git a/src/server/game/DungeonFinding/LFGMgr.cpp b/src/server/game/DungeonFinding/LFGMgr.cpp index 61724fa25..9da1f9199 100644 --- a/src/server/game/DungeonFinding/LFGMgr.cpp +++ b/src/server/game/DungeonFinding/LFGMgr.cpp @@ -165,6 +165,87 @@ namespace lfg LOG_INFO("server.loading", " "); } + void LFGMgr::AddDungeonCooldown(ObjectGuid guid, uint32 dungeonId) + { + if (!sWorld->getIntConfig(CONFIG_LFG_DUNGEON_SELECTION_COOLDOWN)) + return; + + DungeonCooldownStore[guid][dungeonId] = GameTime::Now(); + } + + void LFGMgr::CleanupDungeonCooldowns() + { + if (!sWorld->getIntConfig(CONFIG_LFG_DUNGEON_SELECTION_COOLDOWN)) + return; + + Seconds cooldownDuration = GetDungeonCooldownDuration(); + + for (auto itPlayer = DungeonCooldownStore.begin(); itPlayer != DungeonCooldownStore.end(); ) + { + for (auto itDungeon = itPlayer->second.begin(); itDungeon != itPlayer->second.end(); ) + { + if (GameTime::HasElapsed(itDungeon->second, cooldownDuration)) + itDungeon = itPlayer->second.erase(itDungeon); + else + ++itDungeon; + } + + if (itPlayer->second.empty()) + itPlayer = DungeonCooldownStore.erase(itPlayer); + else + ++itPlayer; + } + } + + void LFGMgr::ClearDungeonCooldowns() + { + DungeonCooldownStore.clear(); + } + + Seconds LFGMgr::GetDungeonCooldownDuration() const + { + return Seconds(sWorld->getIntConfig(CONFIG_LFG_DUNGEON_SELECTION_COOLDOWN) * MINUTE); + } + + LfgDungeonSet LFGMgr::FilterCooldownDungeons(LfgDungeonSet const& dungeons, LfgRolesMap const& players) + { + if (!sWorld->getIntConfig(CONFIG_LFG_DUNGEON_SELECTION_COOLDOWN)) + return dungeons; + + Seconds cooldownDuration = GetDungeonCooldownDuration(); + + LfgDungeonSet filtered; + for (uint32 dungeonId : dungeons) + { + bool onCooldown = false; + for (auto const& playerPair : players) + { + auto itPlayer = DungeonCooldownStore.find(playerPair.first); + if (itPlayer != DungeonCooldownStore.end()) + { + auto itDungeon = itPlayer->second.find(dungeonId); + if (itDungeon != itPlayer->second.end() && !GameTime::HasElapsed(itDungeon->second, cooldownDuration)) + { + onCooldown = true; + break; + } + } + } + + if (!onCooldown) + filtered.insert(dungeonId); + } + + // If all dungeons are on cooldown, return original set to avoid blocking the queue + if (filtered.empty()) + { + LOG_DEBUG("lfg", "LFGMgr::FilterCooldownDungeons: All {} dungeons on cooldown for group, bypassing cooldown filter", dungeons.size()); + return dungeons; + } + + return filtered; + } + LFGDungeonData const* LFGMgr::GetLFGDungeon(uint32 id) { LFGDungeonContainer::const_iterator itr = LfgDungeonStore.find(id); @@ -329,6 +410,9 @@ namespace lfg BootsStore.erase(itBoot); } } + + // Cleanup expired dungeon cooldowns + CleanupDungeonCooldowns(); } else if (task == 1) { @@ -2263,6 +2347,9 @@ namespace lfg continue; } + // Record dungeon cooldown for this player (the actual dungeon completed, not the random entry) + AddDungeonCooldown(guid, dungeonId); + Player* player = ObjectAccessor::FindPlayer(guid); if (!player || player->FindMap() != currMap) // pussywizard: currMap - multithreading crash if on other map (map id check is not enough, binding system is not reliable) { diff --git a/src/server/game/DungeonFinding/LFGMgr.h b/src/server/game/DungeonFinding/LFGMgr.h index 644504567..2164de059 100644 --- a/src/server/game/DungeonFinding/LFGMgr.h +++ b/src/server/game/DungeonFinding/LFGMgr.h @@ -450,6 +450,10 @@ namespace lfg void LoadRewards(); /// Loads dungeons from dbc and adds teleport coords void LoadLFGDungeons(bool reload = false); + /// Filters out recently completed dungeons from the proposal set for the given players + LfgDungeonSet FilterCooldownDungeons(LfgDungeonSet const& dungeons, LfgRolesMap const& players); + /// Clears all dungeon cooldowns for all players + void ClearDungeonCooldowns(); // Multiple files /// Check if given guid applied for random dungeon @@ -636,6 +640,14 @@ namespace lfg LfgPlayerDataContainer PlayersStore; ///< Player data LfgGroupDataContainer GroupsStore; ///< Group data bool m_Testing; + + // Dungeon cooldown system - prevents same dungeon being assigned in a row + typedef std::unordered_map LfgDungeonCooldownMap; + typedef std::unordered_map LfgDungeonCooldownContainer; + LfgDungeonCooldownContainer DungeonCooldownStore; ///< Stores dungeon cooldowns per player + void AddDungeonCooldown(ObjectGuid guid, uint32 dungeonId); + void CleanupDungeonCooldowns(); + [[nodiscard]] Seconds GetDungeonCooldownDuration() const; }; template )> diff --git a/src/server/game/DungeonFinding/LFGQueue.cpp b/src/server/game/DungeonFinding/LFGQueue.cpp index aae5df67a..a7890fc9f 100644 --- a/src/server/game/DungeonFinding/LFGQueue.cpp +++ b/src/server/game/DungeonFinding/LFGQueue.cpp @@ -415,7 +415,10 @@ namespace lfg proposal.cancelTime = GameTime::GetGameTime().count() + LFG_TIME_PROPOSAL; proposal.state = LFG_PROPOSAL_INITIATING; proposal.leader.Clear(); - proposal.dungeonId = Acore::Containers::SelectRandomContainerElement(proposalDungeons); + + // Filter out recently completed dungeons to prevent same dungeon in a row + LfgDungeonSet filteredDungeons = sLFGMgr->FilterCooldownDungeons(proposalDungeons, proposalRoles); + proposal.dungeonId = Acore::Containers::SelectRandomContainerElement(filteredDungeons); uint32 completedEncounters = 0; bool leader = false; diff --git a/src/server/game/Miscellaneous/Language.h b/src/server/game/Miscellaneous/Language.h index 7d76a57cc..7d2b1b9bc 100644 --- a/src/server/game/Miscellaneous/Language.h +++ b/src/server/game/Miscellaneous/Language.h @@ -1347,6 +1347,8 @@ enum AcoreStrings LANG_BAN_IP_YOUBANNEDMESSAGE_WORLD = 11017, LANG_BAN_IP_YOUPERMBANNEDMESSAGE_WORLD = 11018, + LANG_LFG_COOLDOWN_CLEARED = 11019, + LANG_MUTED_PLAYER = 30000, // Mute for player 2 hour // Instant Flight diff --git a/src/server/game/Time/GameTime.h b/src/server/game/Time/GameTime.h index 40d96f6a5..37ffb7bfb 100644 --- a/src/server/game/Time/GameTime.h +++ b/src/server/game/Time/GameTime.h @@ -41,6 +41,19 @@ namespace GameTime /// Uptime AC_GAME_API Seconds GetUptime(); + /// Uptime since a given time point + inline Microseconds Elapsed(TimePoint start) + { + return std::chrono::duration_cast(Now() - start); + } + + /// Check if a duration has elapsed since a given time point + template + inline bool HasElapsed(TimePoint start, T duration) + { + return (Now() - start) >= duration; + } + /// Update all timers void UpdateGameTimers(); } diff --git a/src/server/game/World/WorldConfig.cpp b/src/server/game/World/WorldConfig.cpp index 62759608b..b47200082 100644 --- a/src/server/game/World/WorldConfig.cpp +++ b/src/server/game/World/WorldConfig.cpp @@ -562,6 +562,7 @@ void WorldConfig::BuildConfigCache() SetConfigValue(CONFIG_LFG_OPTIONSMASK, "DungeonFinder.OptionsMask", 5); SetConfigValue(CONFIG_LFG_CAST_DESERTER, "DungeonFinder.CastDeserter", true); SetConfigValue(CONFIG_LFG_ALLOW_COMPLETED, "DungeonFinder.AllowCompleted", true); + SetConfigValue(CONFIG_LFG_DUNGEON_SELECTION_COOLDOWN, "DungeonFinder.DungeonSelectionCooldown", 0); // DBC_ItemAttributes SetConfigValue(CONFIG_DBC_ENFORCE_ITEM_ATTRIBUTES, "DBC.EnforceItemAttributes", true); diff --git a/src/server/game/World/WorldConfig.h b/src/server/game/World/WorldConfig.h index 3cf19aa21..e6604565f 100644 --- a/src/server/game/World/WorldConfig.h +++ b/src/server/game/World/WorldConfig.h @@ -321,6 +321,7 @@ enum ServerConfigs CONFIG_PRESERVE_CUSTOM_CHANNEL_DURATION, CONFIG_PERSISTENT_CHARACTER_CLEAN_FLAGS, CONFIG_LFG_OPTIONSMASK, + CONFIG_LFG_DUNGEON_SELECTION_COOLDOWN, CONFIG_MAX_INSTANCES_PER_HOUR, CONFIG_WINTERGRASP_PLR_MAX, CONFIG_WINTERGRASP_PLR_MIN, diff --git a/src/server/scripts/Commands/cs_lfg.cpp b/src/server/scripts/Commands/cs_lfg.cpp index bfdee4288..693ab1585 100644 --- a/src/server/scripts/Commands/cs_lfg.cpp +++ b/src/server/scripts/Commands/cs_lfg.cpp @@ -47,11 +47,12 @@ public: { static ChatCommandTable lfgCommandTable = { - { "player", HandleLfgPlayerInfoCommand, SEC_MODERATOR, Console::No }, - { "group", HandleLfgGroupInfoCommand, SEC_MODERATOR, Console::No }, - { "queue", HandleLfgQueueInfoCommand, SEC_MODERATOR, Console::Yes }, - { "clean", HandleLfgCleanCommand, SEC_ADMINISTRATOR, Console::Yes }, - { "options", HandleLfgOptionsCommand, SEC_GAMEMASTER, Console::Yes }, + { "player", HandleLfgPlayerInfoCommand, SEC_MODERATOR, Console::No }, + { "group", HandleLfgGroupInfoCommand, SEC_MODERATOR, Console::No }, + { "queue", HandleLfgQueueInfoCommand, SEC_MODERATOR, Console::Yes }, + { "clean", HandleLfgCleanCommand, SEC_ADMINISTRATOR, Console::Yes }, + { "options", HandleLfgOptionsCommand, SEC_GAMEMASTER, Console::Yes }, + { "cooldown", HandleLfgCooldownClearCommand, SEC_ADMINISTRATOR, Console::Yes }, }; static ChatCommandTable commandTable = @@ -126,6 +127,13 @@ public: sLFGMgr->Clean(); return true; } + + static bool HandleLfgCooldownClearCommand(ChatHandler* handler) + { + sLFGMgr->ClearDungeonCooldowns(); + handler->SendSysMessage(LANG_LFG_COOLDOWN_CLEARED); + return true; + } }; void AddSC_lfg_commandscript() diff --git a/src/test/server/game/Time/GameTime.cpp b/src/test/server/game/Time/GameTime.cpp new file mode 100644 index 000000000..ed6428d00 --- /dev/null +++ b/src/test/server/game/Time/GameTime.cpp @@ -0,0 +1,63 @@ +/* + * This file is part of the AzerothCore Project. See AUTHORS file for Copyright information + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program. If not, see . + */ + +#include "GameTime.h" +#include "gtest/gtest.h" + +#include + +TEST(GameTimeTest, Elapsed) +{ + GameTime::UpdateGameTimers(); + auto start = GameTime::Now(); + std::this_thread::sleep_for(50ms); + GameTime::UpdateGameTimers(); + auto elapsed = GameTime::Elapsed(start); + EXPECT_GE(elapsed, 50ms); +} + +TEST(GameTimeTest, HasElapsedTrue) +{ + GameTime::UpdateGameTimers(); + auto start = GameTime::Now(); + std::this_thread::sleep_for(50ms); + GameTime::UpdateGameTimers(); + EXPECT_TRUE(GameTime::HasElapsed(start, 25ms)); +} + +TEST(GameTimeTest, HasElapsedFalse) +{ + GameTime::UpdateGameTimers(); + auto start = GameTime::Now(); + EXPECT_FALSE(GameTime::HasElapsed(start, 10s)); +} + +TEST(GameTimeTest, HasElapsedWithSeconds) +{ + GameTime::UpdateGameTimers(); + auto start = GameTime::Now(); + EXPECT_FALSE(GameTime::HasElapsed(start, 1s)); +} + +TEST(GameTimeTest, HasElapsedWithMicroseconds) +{ + GameTime::UpdateGameTimers(); + auto start = GameTime::Now(); + std::this_thread::sleep_for(100us); + GameTime::UpdateGameTimers(); + EXPECT_TRUE(GameTime::HasElapsed(start, Microseconds(1))); +}