diff --git a/src/game/ChatCommands/DebugCommands.cpp b/src/game/ChatCommands/DebugCommands.cpp index e1770471d..5f3cd3ea4 100644 --- a/src/game/ChatCommands/DebugCommands.cpp +++ b/src/game/ChatCommands/DebugCommands.cpp @@ -35,6 +35,7 @@ */ #include "Common.h" +#include "LFGMgr.h" #include "Database/DatabaseEnv.h" #include "WorldPacket.h" #include "Player.h" @@ -1071,6 +1072,80 @@ bool ChatHandler::HandleDebugGetItemStateCommand(char* args) * @param args Command arguments. * @returns True if the command executed successfully, false otherwise. */ +/** + * @brief Handler for the `.debug dungeon` command. + * + * Mirrors `.debug bg`, which lets a battleground start 1v0 so it can be tested without + * finding nineteen other people. The dungeon finder has the same problem and worse: a + * normal five-man will not form until 1 tank, 1 healer and 3 damage are all present, so + * the proposal, group-creation and teleport paths are unreachable on a test realm. + * + * .debug dungeon a game master's queue entry completes on its own + * .debug dungeon group as above, and it also absorbs whoever else is waiting, + * whatever roles they picked + * .debug dungeon off back to normal matchmaking + * + * While any mode is active a game master leads the resulting dungeon group regardless of + * who holds the leader bit. + * + * The relaxations are scoped to entries that actually contain a game master, so ordinary + * players continue to match each other by the normal rules while this is on. + * + * @param args "group", "off", or empty for solo mode. + * @returns True if the command executed successfully, false otherwise. + */ +bool ChatHandler::HandleDebugDungeonCommand(char* args) +{ + char* mode = ExtractLiteralArg(&args); + + LFGDebugMode newMode = LFG_DEBUG_SOLO; + if (mode) + { + if (!stricmp(mode, "group")) + { + newMode = LFG_DEBUG_GROUP; + } + else if (!stricmp(mode, "off")) + { + newMode = LFG_DEBUG_OFF; + } + else + { + SendSysMessage("Usage: .debug dungeon [group|off]"); + SetSentErrorMessage(true); + return false; + } + } + else if (sLFGMgr.GetDebugMode() != LFG_DEBUG_OFF) + { + // Bare `.debug dungeon` toggles off when something is already on, so the command + // behaves like `.debug bg` when used without arguments. + newMode = LFG_DEBUG_OFF; + } + + sLFGMgr.SetDebugMode(newMode); + + switch (newMode) + { + case LFG_DEBUG_SOLO: + SendSysMessage("Dungeon finder debug ON: a game master's queue entry now forms a group on its own."); + break; + case LFG_DEBUG_GROUP: + SendSysMessage("Dungeon finder debug ON (group): a game master's entry now also takes whoever else is queued, whatever roles they picked."); + break; + default: + SendSysMessage("Dungeon finder debug OFF: normal matchmaking."); + break; + } + + if (newMode != LFG_DEBUG_OFF) + { + SendSysMessage("Ordinary players still match by the normal rules; only entries containing a game master are affected."); + } + + return true; +} + bool ChatHandler::HandleDebugBattlegroundCommand(char* /*args*/) { sBattleGroundMgr.ToggleTesting(); diff --git a/src/game/Server/Opcodes.cpp b/src/game/Server/Opcodes.cpp index 8809d54e9..9cbb99d45 100644 --- a/src/game/Server/Opcodes.cpp +++ b/src/game/Server/Opcodes.cpp @@ -804,6 +804,8 @@ void InitializeOpcodes() // Empty 18414 status refresh request. The handler replies through the // already-converted unified SMSG_LFG_UPDATE_STATUS body. + DefC(CMSG_LFG_SET_ROLES, "CMSG_LFG_SET_ROLES", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLfgSetRolesOpcode); + DefC(CMSG_LFG_PROPOSAL_RESPONSE, "CMSG_LFG_PROPOSAL_RESPONSE", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLfgProposalResponseOpcode); DefC(CMSG_LFG_GET_STATUS, "CMSG_LFG_GET_STATUS", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLfgGetStatusOpcode); // Direct 18414 LFR-browser request and empty full-replacement response. @@ -1128,6 +1130,12 @@ void InitializeOpcodes() // Direct 18414 leaf: periodic queue wait estimates and role vacancies. DefS(SMSG_LFG_QUEUE_STATUS, "SMSG_LFG_QUEUE_STATUS"); + DefS(SMSG_LFG_JOIN_RESULT, "SMSG_LFG_JOIN_RESULT"); + DefS(SMSG_LFG_PROPOSAL_UPDATE, "SMSG_LFG_PROPOSAL_UPDATE"); + DefS(SMSG_LFG_ROLE_CHECK_UPDATE, "SMSG_LFG_ROLE_CHECK_UPDATE"); + DefS(SMSG_LFG_TELEPORT_DENIED, "SMSG_LFG_TELEPORT_DENIED"); + // Body is a single MSB-first bit (0x80 out, 0x00 in) -- see HandleLfgTeleportOpcode. + DefC(CMSG_LFG_TELEPORT, "CMSG_LFG_TELEPORT", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLfgTeleportOpcode); // Wave 13 talent-respec confirmation request and prompt. DefC(CMSG_CONFIRM_RESPEC_WIPE, "CMSG_CONFIRM_RESPEC_WIPE", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleTalentWipeConfirmOpcode); diff --git a/src/game/Server/Opcodes_reference.h b/src/game/Server/Opcodes_reference.h index 168051a63..e63d7359d 100644 --- a/src/game/Server/Opcodes_reference.h +++ b/src/game/Server/Opcodes_reference.h @@ -1575,7 +1575,7 @@ typedef uint16_t uint16; * CMSG_INSPECT_RATED_BG_STATS 0x0882 DORMANT * CMSG_RAID_TARGET_UPDATE 0x0886 ACTIVE * CMSG_UNKNOWN_0x0896 0x0896 DOC - * CMSG_LFG_SET_ROLES 0x08A2 DORMANT + * CMSG_LFG_SET_ROLES 0x08A2 REGISTERED * CMSG_RANDOM_ROLL 0x08A3 DOC * CMSG_REORDER_CHARACTERS 0x08A7 DORMANT * CMSG_MESSAGECHAT_ADDON_INSTANCE 0x08AF ACTIVE @@ -1877,7 +1877,7 @@ typedef uint16_t uint16; * CMSG_UPDATE_CLIENT_SETTINGS 0x1D8D DOC * CMSG_CALENDAR_EVENT_INVITE 0x1D8E DORMANT * CMSG_UNKNOWN_0x1D9B 0x1D9B DOC - * CMSG_LFG_PROPOSAL_RESPONSE 0x1D9D DORMANT + * CMSG_LFG_PROPOSAL_RESPONSE 0x1D9D REGISTERED * CMSG_LF_GUILD_SET_GUILD_POST 0x1D9F DOC * CMSG_QUEST_NPC_QUERY 0x1DAE ACTIVE * CMSG_UNKNOWN_0x1DB9 0x1DB9 DOC diff --git a/src/game/Server/WorldSession.cpp b/src/game/Server/WorldSession.cpp index d01a2c9b2..27de0e53b 100644 --- a/src/game/Server/WorldSession.cpp +++ b/src/game/Server/WorldSession.cpp @@ -604,9 +604,13 @@ static bool IsEnterWorldConverted(uint16 opcode) case SMSG_SAVE_GUILD_EMBLEM: // MopGuildPackets::BuildSaveGuildEmblemResult case SMSG_BINDER_CONFIRM: // MopBindPackets::BuildBinderConfirm case SMSG_PLAYERBOUND: // MopBindPackets::BuildPlayerBound + case SMSG_LFG_PROPOSAL_UPDATE: // MopLfgPackets::BuildProposalUpdate, byte-exact vs capture-000044 seq 1948 and capture-000059 seq 2063424 + case SMSG_LFG_ROLE_CHECK_UPDATE: // MopLfgPackets::BuildRoleCheckUpdate, byte-exact vs capture-000075 seq 891708 and capture-000059 seq 719547 case SMSG_LFG_BOOT_PLAYER: // MopLfgPackets::BuildBootPlayer case SMSG_LFG_UPDATE_STATUS: // MopLfgPackets::BuildUpdateStatus case SMSG_LFG_QUEUE_STATUS: // MopLfgPackets::BuildQueueStatus + case SMSG_LFG_JOIN_RESULT: // MopLfgPackets::BuildJoinResult, byte-exact vs capture-000059 seq 490545 (18B refusal), + // capture-000044 seq 1547 (23B) and capture-000075 seq 891753 (24B) case SMSG_LFG_PLAYER_INFO: // MopLfgPackets::BuildEmptyPlayerInfo case SMSG_LFG_PARTY_INFO: // MopLfgPackets::BuildEmptyPartyInfo case SMSG_LFG_UPDATE_SEARCH: // MopLfgPackets::BuildEmptyLfrSearchResponse diff --git a/src/game/Server/WorldSession.h b/src/game/Server/WorldSession.h index efb123d60..90d3674d3 100644 --- a/src/game/Server/WorldSession.h +++ b/src/game/Server/WorldSession.h @@ -1559,7 +1559,7 @@ class WorldSession SendNotification(format, args...); } void SendPetNameInvalid(uint32 error, const std::string& name, DeclinedName* declinedName); - void SendLfgJoinResult(LfgJoinResult result, LFGState state, partyForbidden const& lockedDungeons); + void SendLfgJoinResult(LfgJoinResult result, uint8 detail, partyForbidden const& lockedDungeons); void SendLfgUpdate(bool isGroup, LFGPlayerStatus status); void SendLfgQueueStatus(LFGQueueStatus const& status); void SendLfgPlayerLockInfo(); @@ -2209,7 +2209,10 @@ class WorldSession void HandleLfrLeaveOpcode(WorldPacket& recv_data); void HandleLfgJoinOpcode(WorldPacket& recv_data); void HandleLfgLeaveOpcode(WorldPacket& recv_data); + void HandleLfgSetRolesOpcode(WorldPacket& recv_data); + void HandleLfgProposalResponseOpcode(WorldPacket& recv_data); void HandleLfgGetStatusOpcode(WorldPacket& recv_data); + void HandleLfgTeleportOpcode(WorldPacket& recv_data); void HandleLfgLockInfoRequestOpcode(WorldPacket& recv_data); void HandleSetLfgCommentOpcode(WorldPacket& recv_data); void HandleSetTitleOpcode(WorldPacket& recv_data); diff --git a/src/game/Server/tests/CMakeLists.txt b/src/game/Server/tests/CMakeLists.txt index f8270539b..22a68b08d 100644 --- a/src/game/Server/tests/CMakeLists.txt +++ b/src/game/Server/tests/CMakeLists.txt @@ -330,6 +330,86 @@ if(WIN32) "PATH=path_list_prepend:${MOP_LFG_LEAVE_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") endif() +add_executable(mop_lfg_player_info_packets_test + mop_lfg_player_info_packets_test.cpp) +target_include_directories(mop_lfg_player_info_packets_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_SOURCE_DIR}/src/shared) +set_target_properties(mop_lfg_player_info_packets_test PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +target_link_libraries(mop_lfg_player_info_packets_test PRIVATE game) +add_test(NAME mop_lfg_player_info_packets COMMAND mop_lfg_player_info_packets_test) +if(WIN32) + get_filename_component(MOP_LFG_PI_MYSQL_RUNTIME_DIR "${MySQL_LIBRARY}" DIRECTORY) + set_tests_properties(mop_lfg_player_info_packets PROPERTIES + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:${MOP_LFG_PI_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") +endif() + +add_executable(mop_lfg_proposal_response_packets_test + mop_lfg_proposal_response_packets_test.cpp) +target_include_directories(mop_lfg_proposal_response_packets_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_SOURCE_DIR}/src/shared) +set_target_properties(mop_lfg_proposal_response_packets_test PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +target_link_libraries(mop_lfg_proposal_response_packets_test PRIVATE game) +add_test(NAME mop_lfg_proposal_response_packets COMMAND mop_lfg_proposal_response_packets_test) +if(WIN32) + get_filename_component(MOP_LFG_PR_MYSQL_RUNTIME_DIR "${MySQL_LIBRARY}" DIRECTORY) + set_tests_properties(mop_lfg_proposal_response_packets PROPERTIES + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:${MOP_LFG_PR_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") +endif() + +add_executable(mop_lfg_proposal_packets_test + mop_lfg_proposal_packets_test.cpp) +target_include_directories(mop_lfg_proposal_packets_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_SOURCE_DIR}/src/shared) +set_target_properties(mop_lfg_proposal_packets_test PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +target_link_libraries(mop_lfg_proposal_packets_test PRIVATE game) +add_test(NAME mop_lfg_proposal_packets COMMAND mop_lfg_proposal_packets_test) +if(WIN32) + get_filename_component(MOP_LFG_PROP_MYSQL_RUNTIME_DIR "${MySQL_LIBRARY}" DIRECTORY) + set_tests_properties(mop_lfg_proposal_packets PROPERTIES + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:${MOP_LFG_PROP_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") +endif() + +add_executable(mop_lfg_role_check_packets_test + mop_lfg_role_check_packets_test.cpp) +target_include_directories(mop_lfg_role_check_packets_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_SOURCE_DIR}/src/shared) +set_target_properties(mop_lfg_role_check_packets_test PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +target_link_libraries(mop_lfg_role_check_packets_test PRIVATE game) +add_test(NAME mop_lfg_role_check_packets COMMAND mop_lfg_role_check_packets_test) +if(WIN32) + get_filename_component(MOP_LFG_RC_MYSQL_RUNTIME_DIR "${MySQL_LIBRARY}" DIRECTORY) + set_tests_properties(mop_lfg_role_check_packets PROPERTIES + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:${MOP_LFG_RC_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") +endif() + +add_executable(mop_lfg_set_roles_packets_test + mop_lfg_set_roles_packets_test.cpp) +target_include_directories(mop_lfg_set_roles_packets_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_SOURCE_DIR}/src/shared) +set_target_properties(mop_lfg_set_roles_packets_test PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +target_link_libraries(mop_lfg_set_roles_packets_test PRIVATE game) +add_test(NAME mop_lfg_set_roles_packets COMMAND mop_lfg_set_roles_packets_test) +if(WIN32) + get_filename_component(MOP_LFG_SET_ROLES_MYSQL_RUNTIME_DIR "${MySQL_LIBRARY}" DIRECTORY) + set_tests_properties(mop_lfg_set_roles_packets PROPERTIES + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:${MOP_LFG_SET_ROLES_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") +endif() + add_executable(mop_group_role_poll_packets_test mop_group_role_poll_packets_test.cpp) target_include_directories(mop_group_role_poll_packets_test PRIVATE diff --git a/src/game/Server/tests/mop_lfg_packets_test.cpp b/src/game/Server/tests/mop_lfg_packets_test.cpp index a00155720..b1992d994 100644 --- a/src/game/Server/tests/mop_lfg_packets_test.cpp +++ b/src/game/Server/tests/mop_lfg_packets_test.cpp @@ -135,6 +135,78 @@ static void test_queue_status_exact_fixture() })); } +/// SMSG_LFG_JOIN_RESULT, pinned to the three real payload shapes in the corpus +/// (build 18414, catalogue 2BE10C89). These are captured bytes, not synthesised +/// ones: the previous body was the 3.3.5 layout and would fail every one of them +/// on the first byte, so a shape-only test would not have caught it. +/// +/// The GUID mask is SPLIT either side of the 22-bit lock count, which is what makes +/// the length identity len == 18 + popcount(byte0) + popcount(byte3). +static void test_join_result_refusal_fixture() +{ + // capture-000059 seq 490545: role check failed, detail 6 (LFG_ROLECHECK_NO_ROLE). + // A refusal zeroes the GUID and the entire ticket -- that is what makes it 18 bytes. + MopLfgPackets::JoinResult update; + update.result = 0x1C; + update.detail = 6; + + WorldPacket packet(SMSG_LFG_JOIN_RESULT, 24); + MopLfgPackets::BuildJoinResult(packet, update); + CHECK(Equal(packet, { + 0x00,0x00,0x00,0x00, + 0x1C, 0x06, + 0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00 + })); +} + +static void test_join_result_success_23_fixture() +{ + // capture-000044 seq 1547. The ticket here is the SAME one carried by + // SMSG_LFG_QUEUE_STATUS seq 1577 in this capture: joinTime 0x54146107, + // queueId 0x9BFF. Three of the eight GUID bytes are zero, hence 23 not 26. + MopLfgPackets::JoinResult update; + update.requesterGuid = 0x0400000006296291ULL; + update.joinTime = 0x54146107; + update.clientQueueId = 0x9BFF; + update.ticketType = 3; + + WorldPacket packet(SMSG_LFG_JOIN_RESULT, 32); + MopLfgPackets::BuildJoinResult(packet, update); + CHECK(Equal(packet, { + 0xB0, 0x00, 0x00, 0x14, + 0x00, 0x00, + 0x28, + 0x07, 0x61, 0x14, 0x54, + 0xFF, 0x9B, 0x00, 0x00, + 0x03, 0x00, 0x00, 0x00, + 0x63, 0x90, 0x05, 0x07 + })); +} + +static void test_join_result_success_24_fixture() +{ + // capture-000075 seq 891753: a different GUID with one more non-zero byte. + MopLfgPackets::JoinResult update; + update.requesterGuid = 0x1F5400001249B4F0ULL; + update.joinTime = 0x53D28F06; + update.clientQueueId = 0x4692; + update.ticketType = 3; + + WorldPacket packet(SMSG_LFG_JOIN_RESULT, 32); + MopLfgPackets::BuildJoinResult(packet, update); + CHECK(Equal(packet, { + 0xF0, 0x00, 0x00, 0x14, + 0x00, 0x00, + 0x48, + 0x06, 0x8F, 0xD2, 0x53, + 0x92, 0x46, 0x00, 0x00, + 0x03, 0x00, 0x00, 0x00, + 0x55, 0xB5, 0xF1, 0x1E, 0x13 + })); +} + static void test_lock_info_request() { WorldPacket player(CMSG_LFG_LOCK_INFO_REQUEST, 2); @@ -182,6 +254,9 @@ int main(int /*argc*/, char** /*argv*/) { test_update_status_exact_fixture(); test_queue_status_exact_fixture(); + test_join_result_refusal_fixture(); + test_join_result_success_23_fixture(); + test_join_result_success_24_fixture(); test_lock_info_request(); test_lfr_search_request(); diff --git a/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp b/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp new file mode 100644 index 000000000..48232fd46 --- /dev/null +++ b/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp @@ -0,0 +1,154 @@ +/** + * Byte-exact coverage for the SMSG_LFG_PLAYER_INFO (0x1861) lock array. + * + * The expected bytes are REAL captured server bytes at build 18414, lifted from a live + * reply, not inverses of our own writer. + * + * Corpus catalogueGenerationId + * 2BE10C899585BAECD237705AC13BBF9262D81B6BDC085B462808C6869CE88752 + * Reference packet: capture-000006 sequence 1953, 6068 bytes, sent to a max-level + * character. Decoded header: lockCount 206, hasPlayerGuid 0, randomDungeonCount 35. + * + * Layout: + * bits WriteBits(lockCount, 20) + * WriteBit(hasPlayerGuid) + * WriteBits(randomDungeonCount, 17) + * FlushBits -> 38 bits, 5 bytes + * ...random dungeon reward records, variable length... + * tail lockCount x 16 bytes, flat and unpacked: + * uint32 dungeonEntry (TypeID << 24) | id + * uint32 lockStatus -- the client's LFG_INSTANCE_INVALID_CODES + * uint32 subReason1 + * uint32 subReason2 + * + * We send a locks-only reply (randomDungeonCount 0), which the client accepts: it + * installs the lock list and raises LFG_LOCK_INFO_RECEIVED whether or not random records + * follow. So the header differs from the reference by design, but the LOCK ARRAY must be + * byte-identical -- that is what these cases assert. + */ + +#include "LFGMgr.h" +#include "WorldPacket.h" + +#include +#include +#include + +namespace +{ + void AssertBytes(uint8 const* actual, std::vector const& expected, + size_t offset, char const* label) + { + for (size_t i = 0; i < expected.size(); ++i) + { + if (actual[offset + i] != expected[i]) + { + std::printf("%s: byte %u is 0x%02X, expected 0x%02X\n", label, + unsigned(offset + i), actual[offset + i], expected[i]); + assert(false); + } + } + } + + MopLfgPackets::PlayerLockInfo Lock(uint32 entry, uint32 status) + { + MopLfgPackets::PlayerLockInfo l; + l.dungeonEntry = entry; + l.lockStatus = status; + return l; + } + + /// The first five lock records of capture-000006 seq 1953, byte for byte. + /// + /// All five are lockStatus 3 -- LEVEL_TOO_HIGH -- which is what a max-level character + /// sees for low-level content, and 167 of that packet's 206 records carry it. Note + /// the third is a TypeID 2 (raid) entry, so the array is not dungeons-only. + void test_lock_records_match_capture() + { + std::vector const expected = { + 0xBC, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xAA, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xA0, 0x00, 0x00, 0x02, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x93, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xA3, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + }; + + std::vector locks; + locks.push_back(Lock(0x010000BCu, 3)); + locks.push_back(Lock(0x010000AAu, 3)); + locks.push_back(Lock(0x020000A0u, 3)); // raid entry, TypeID 2 + locks.push_back(Lock(0x01000093u, 3)); + locks.push_back(Lock(0x010000A3u, 3)); + + WorldPacket packet(SMSG_LFG_PLAYER_INFO, 5 + locks.size() * 16); + MopLfgPackets::BuildPlayerInfo(packet, locks); + + assert(packet.size() == 5 + expected.size()); // 5-byte header, then the array + AssertBytes(packet.contents(), expected, 5, "lock_records"); + } + + /// The 20/1/17 bit header, checked against the reference packet's own first five bytes. + /// + /// Feeding the reference counts back in must reproduce them exactly; this is what pins + /// the field widths and their order. + void test_header_matches_capture() + { + // 206 locks, hasPlayerGuid 0, 35 random records -> 00 0C E0 00 8D + std::vector const expectedHeader = { 0x00, 0x0C, 0xE0, 0x00, 0x8D }; + + WorldPacket packet(SMSG_LFG_PLAYER_INFO, 5); + packet.WriteBits(206, 20); + packet.WriteBit(false); + packet.WriteBits(35, 17); + packet.FlushBits(); + + assert(packet.size() == expectedHeader.size()); + AssertBytes(packet.contents(), expectedHeader, 0, "header"); + } + + /// Our own locks-only header: same widths, random count zero. + void test_locks_only_header() + { + std::vector locks; + locks.push_back(Lock(0x010000BCu, 3)); + + WorldPacket packet(SMSG_LFG_PLAYER_INFO, 5 + 16); + MopLfgPackets::BuildPlayerInfo(packet, locks); + + assert(packet.size() == 5 + 16); + + // Decode the header back out and confirm the counts survive the round trip. + uint8 const* b = packet.contents(); + uint32 const bits = (uint32(b[0]) << 24) | (uint32(b[1]) << 16) | + (uint32(b[2]) << 8) | uint32(b[3]); + assert((bits >> 12) == 1); // 20-bit lock count + assert(((bits >> 11) & 1) == 0); // hasPlayerGuid + } + + /// An empty lock list must still emit a well-formed 5-byte header, because that is + /// what a character with nothing locked legitimately produces. + void test_empty_lock_list() + { + std::vector locks; + + WorldPacket packet(SMSG_LFG_PLAYER_INFO, 5); + MopLfgPackets::BuildPlayerInfo(packet, locks); + + assert(packet.size() == 5); + for (size_t i = 0; i < packet.size(); ++i) + { + assert(packet.contents()[i] == 0x00); + } + } +} + +int main() +{ + test_lock_records_match_capture(); + test_header_matches_capture(); + test_locks_only_header(); + test_empty_lock_list(); + + std::printf("mop_lfg_player_info_packets_test: OK\n"); + return 0; +} diff --git a/src/game/Server/tests/mop_lfg_proposal_packets_test.cpp b/src/game/Server/tests/mop_lfg_proposal_packets_test.cpp new file mode 100644 index 000000000..d10b2f55a --- /dev/null +++ b/src/game/Server/tests/mop_lfg_proposal_packets_test.cpp @@ -0,0 +1,162 @@ +/** + * Byte-exact coverage for the SMSG_LFG_PROPOSAL_UPDATE (0x1E3B) writer. + * + * The expected bodies are REAL captured server bytes at build 18414. The test feeds the + * writer the values decoded out of each capture and asserts it reproduces that capture + * byte for byte, so this compares against traffic a retail server actually sent rather + * than round-tripping our own assumptions. + * + * Corpus catalogueGenerationId + * 2BE10C899585BAECD237705AC13BBF9262D81B6BDC085B462808C6869CE88752 + * + * The two cases are deliberately as different as the corpus allows -- a 5-man dungeon + * proposal and a 25-man raid finder proposal -- because a layout that only fits one + * shape proves nothing. The raid case is a useful independent check on the decode: its + * 2 tank / 6 healer / 17 dps composition is exactly what LfgDungeons.dbc carries in + * Count_tank/Count_healer/Count_damage for LFR rows, established from the DBC and not + * from this packet. + */ + +#include "LFGMgr.h" +#include "WorldPacket.h" + +#include +#include +#include + +namespace +{ + void AssertBytes(WorldPacket const& packet, std::vector const& expected, + char const* label) + { + if (packet.size() != expected.size()) + { + std::printf("%s: size %u, expected %u\n", label, + unsigned(packet.size()), unsigned(expected.size())); + assert(false); + } + + for (size_t i = 0; i < expected.size(); ++i) + { + if (packet.contents()[i] != expected[i]) + { + std::printf("%s: byte %u is 0x%02X, expected 0x%02X\n", label, + unsigned(i), packet.contents()[i], expected[i]); + assert(false); + } + } + } + + MopLfgPackets::ProposalPlayer Player(uint32 roles, bool isSelf) + { + MopLfgPackets::ProposalPlayer entry; + entry.roles = roles; + entry.isSelf = isSelf; + return entry; + } + + /// capture-000044 seq 1948, 64 bytes. + /// + /// A five-man proposal in its initial state: nobody has answered yet, and the only + /// bit set on any player is "this is you" on entry 0. Roles are 0x03 (LEADER|TANK), + /// 0x04 (HEALER) and three 0x08 (DAMAGE) -- a textbook 1/1/3. + void test_five_man_proposal() + { + std::vector const expected = { + 0xF0, 0xB8, 0x00, 0x01, 0x50, 0x00, 0x00, 0x13, 0x2C, 0x05, 0x28, + 0x90, 0x03, 0x01, 0x00, 0x06, 0x00, 0xFF, 0x9B, 0x00, 0x00, 0x45, + 0x9C, 0x84, 0x00, 0x00, 0x07, 0x07, 0x61, 0x14, 0x54, 0x03, 0x00, + 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, + 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x1E, 0x63, 0x04, 0xD6, 0x03, 0x00, 0x00, 0x00, 0x10 + }; + + MopLfgPackets::ProposalUpdate update; + update.requesterGuid = 0x0400000006296291ULL; + update.instanceGuid = 0x1F44000011D72D05ULL; + update.dungeonEntry = 0x06000103u; + update.state = 0; + update.clientQueueId = 39935; + update.proposalId = 33948; + update.joinTime = 1410621703u; + update.encounters = 0; + update.flags = 3; + update.silent = false; + + update.players.push_back(Player(0x03, true)); + update.players.push_back(Player(0x04, false)); + update.players.push_back(Player(0x08, false)); + update.players.push_back(Player(0x08, false)); + update.players.push_back(Player(0x08, false)); + + WorldPacket packet(SMSG_LFG_PROPOSAL_UPDATE, expected.size()); + MopLfgPackets::BuildProposalUpdate(packet, update); + + AssertBytes(packet, expected, "five_man_proposal"); + } + + /// capture-000059 seq 2063424, 156 bytes. + /// + /// A 25-man raid finder proposal. The recipient is entry 6, not entry 0, which is + /// why the "is this you" bit cannot be assumed to sit on the first player. + /// + /// Roles include 0x32 and 0x09: bits above DAMAGE are real and must be passed + /// through verbatim rather than masked to the four known role bits. + void test_raid_finder_proposal() + { + std::vector const expected = { + 0xB0, 0xB8, 0x00, 0x06, 0x40, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x30, 0xF3, + 0x05, 0xFF, 0xD5, 0xCC, 0x02, 0x00, 0x01, 0x00, 0x6F, 0x93, 0x00, + 0x00, 0x45, 0x7C, 0x2B, 0x00, 0x00, 0x04, 0xE7, 0x2D, 0xFF, 0x53, + 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, + 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, + 0x00, 0x00, 0x32, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x32, + 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, + 0x00, 0x08, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x08, 0x00, + 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, + 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x1E, 0x4D, 0x4D, 0x03, 0x00, 0x00, + 0x00, 0x10 + }; + + static uint32 const roles[25] = { + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x32, 0x08, 0x32, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x09, 0x08, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08 + }; + + MopLfgPackets::ProposalUpdate update; + update.requesterGuid = 0x0400000005FE4CD4ULL; + update.instanceGuid = 0x1F440000114CF200ULL; + update.dungeonEntry = 0x010002CCu; + update.state = 0; + update.clientQueueId = 37743; + update.proposalId = 11132; + update.joinTime = 1409232359u; + update.encounters = 0; + update.flags = 3; + update.silent = false; + + for (size_t i = 0; i < 25; ++i) + { + update.players.push_back(Player(roles[i], i == 6)); + } + + WorldPacket packet(SMSG_LFG_PROPOSAL_UPDATE, expected.size()); + MopLfgPackets::BuildProposalUpdate(packet, update); + + AssertBytes(packet, expected, "raid_finder_proposal"); + } +} + +int main() +{ + test_five_man_proposal(); + test_raid_finder_proposal(); + + std::printf("mop_lfg_proposal_packets_test: OK\n"); + return 0; +} diff --git a/src/game/Server/tests/mop_lfg_proposal_response_packets_test.cpp b/src/game/Server/tests/mop_lfg_proposal_response_packets_test.cpp new file mode 100644 index 000000000..e08462dc4 --- /dev/null +++ b/src/game/Server/tests/mop_lfg_proposal_response_packets_test.cpp @@ -0,0 +1,101 @@ +/** + * Byte-exact coverage for the CMSG_LFG_PROPOSAL_RESPONSE (0x1D9D) reader. + * + * The body below is REAL captured client bytes at build 18414, not an inverse of our + * own writer. + * + * Corpus catalogueGenerationId + * 2BE10C899585BAECD237705AC13BBF9262D81B6BDC085B462808C6869CE88752 + * + * Layout derived from the client's body writer sub_66A29E, reached as vtable slot 1 + * behind the opcode thunk sub_6622E8 (which writes 7581). GUID A is at this+24..31 and + * GUID B at this+48..55: + * + * uint32 proposalId, clientQueueId, flags, joinTime + * bits accept, then the mask A6 A0 A2 A4 B6 B7 A3 B4 A7 B1 A5 B0 A1 B2 B3 B5 + * Flush + * bytes A3 A6 A4 A1 B7 B0 A7 B6 A5 B3 B1 B5 B4 A0 A2 B2, each XOR 1 when present + * + * What makes this case worth having is that it closes a loop. capture-000059 seq + * 2063770 is the client's answer to seq 2063424 in the SAME capture -- the 156-byte + * SMSG_LFG_PROPOSAL_UPDATE covered by mop_lfg_proposal_packets_test. Every echoed field + * matches what that packet carried: proposal 11132, queue 37743, flags 3, join time + * 1409232359, and both GUIDs. The inbound and outbound derivations were done separately + * and agree, which neither could establish alone. + */ + +#include "Group.h" +#include "WorldPacket.h" + +#include +#include +#include + +namespace +{ + WorldPacket MakeBody(std::vector const& bytes) + { + WorldPacket packet(CMSG_LFG_PROPOSAL_RESPONSE, bytes.size()); + packet.append(bytes.data(), bytes.size()); + return packet; + } + + /// capture-000059 seq 2063770, 29 bytes: an ACCEPT of the 25-man raid proposal. + void test_accept_body() + { + std::vector const body = { + 0x7C, 0x2B, 0x00, 0x00, 0x6F, 0x93, 0x00, 0x00, 0x03, 0x00, + 0x00, 0x00, 0xE7, 0x2D, 0xFF, 0x53, 0xB7, 0x67, 0x00, 0x04, + 0x4D, 0x1E, 0x05, 0x45, 0x10, 0xF3, 0xD5, 0xFF, 0x4D + }; + + WorldPacket packet = MakeBody(body); + MopLfgProposalResponsePackets::Request request; + assert(MopLfgProposalResponsePackets::ParseRequest(packet, request)); + + assert(request.accepted); + assert(request.proposalId == 11132); + assert(request.clientQueueId == 37743); + assert(request.flags == 3); + assert(request.joinTime == 1409232359u); + + // Both GUIDs match the SMSG_LFG_PROPOSAL_UPDATE this is answering. + assert(request.guidA.GetRawValue() == 0x0400000005FE4CD4ULL); + assert(request.guidB.GetRawValue() == 0x1F440000114CF200ULL); + + assert(packet.rpos() == packet.size()); // no tail left unread + } + + /// A body truncated inside its GUID run must be refused, not read past its end. + void test_truncated_body_is_refused() + { + std::vector const body = { + 0x7C, 0x2B, 0x00, 0x00, 0x6F, 0x93, 0x00, 0x00, 0x03, 0x00, + 0x00, 0x00, 0xE7, 0x2D, 0xFF, 0x53, 0xB7, 0x67, 0x00, 0x04 + }; + + WorldPacket packet = MakeBody(body); + MopLfgProposalResponsePackets::Request request; + assert(!MopLfgProposalResponsePackets::ParseRequest(packet, request)); + } + + /// Shorter than the fixed header plus its mask bytes. + void test_short_body_is_refused() + { + std::vector const body = { 0x7C, 0x2B, 0x00, 0x00 }; + + WorldPacket packet = MakeBody(body); + MopLfgProposalResponsePackets::Request request; + assert(!MopLfgProposalResponsePackets::ParseRequest(packet, request)); + } +} + +int main() +{ + test_accept_body(); + test_truncated_body_is_refused(); + test_short_body_is_refused(); + + std::printf("mop_lfg_proposal_response_packets_test: OK\n"); + return 0; +} diff --git a/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp b/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp new file mode 100644 index 000000000..a99083af6 --- /dev/null +++ b/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp @@ -0,0 +1,156 @@ +/** + * Byte-exact coverage for the SMSG_LFG_ROLE_CHECK_UPDATE (0x12BB) writer. + * + * The expected bodies below are REAL captured server bytes at build 18414. The test + * feeds the writer the values decoded out of each capture and asserts the writer + * reproduces that capture byte for byte -- so this is not a round-trip of our own + * assumptions, it is a comparison against traffic a retail server actually sent. + * + * Corpus catalogueGenerationId + * 2BE10C899585BAECD237705AC13BBF9262D81B6BDC085B462808C6869CE88752 + * + * Layout (see MopLfgPackets::BuildRoleCheckUpdate): + * + * uint8 partyIndex + * uint8 state + * bits WriteBits(memberCount, 21) + * per member: WriteBit(answered), guid mask [3,0,5,2,7,1,4,6] + * rdg[3], rdg[5], WriteBits(dungeonCount, 22), rdg[0,7,6,1,4,2], + * WriteBit(state == LFG_ROLECHECK_INITIALITING) + * FlushBits + * bytes ByteSeq rdg[0] + * per member: uint8 level, seq[3], seq[6], uint32 roles, seq[2,4,0,1,5,7] + * ByteSeq rdg[1,7,6,4,3,2,5] + * dungeonCount x uint32 dungeon entry + * + * The two cases differ in size, member count, party index and dungeon type, which is + * what makes the agreement meaningful -- a layout that only fits one shape proves + * nothing. + */ + +#include "LFGMgr.h" +#include "WorldPacket.h" + +#include +#include +#include + +namespace +{ + void AssertBytes(WorldPacket const& packet, std::vector const& expected, + char const* label) + { + if (packet.size() != expected.size()) + { + std::printf("%s: size %u, expected %u\n", label, + unsigned(packet.size()), unsigned(expected.size())); + assert(false); + } + + for (size_t i = 0; i < expected.size(); ++i) + { + if (packet.contents()[i] != expected[i]) + { + std::printf("%s: byte %u is 0x%02X, expected 0x%02X\n", label, + unsigned(i), packet.contents()[i], expected[i]); + assert(false); + } + } + } + + /// capture-000075 seq 891708, 35 bytes. + /// + /// A two-man role check. Member 0 is the leader and has answered with 0x0A -- + /// TANK|DAMAGE, a hybrid -- while member 1 has not answered at all (roles 0, + /// answered bit clear). Both are level 90. One dungeon, entry 0x010002CD. + void test_two_member_role_check() + { + std::vector const expected = { + 0x00, 0x02, 0x00, 0x00, 0x17, 0x71, 0xB8, 0x00, 0x00, 0x02, 0x04, + 0x5A, 0x04, 0x0A, 0x00, 0x00, 0x00, 0x49, 0xD0, 0x28, 0x05, + 0x5A, 0x04, 0x00, 0x00, 0x00, 0x00, 0x4B, 0xD5, 0xC3, 0x05, + 0xCD, 0x02, 0x00, 0x01 + }; + + MopLfgPackets::RoleCheckUpdate update; + update.partyIndex = 0; + update.state = LFG_ROLECHECK_INITIALITING; + + MopLfgPackets::RoleCheckMember leader; + leader.guid = 0x04000000054829D1ULL; + leader.roles = 0x0A; + leader.level = 90; + update.members.push_back(leader); + + MopLfgPackets::RoleCheckMember other; + other.guid = 0x04000000054AC2D4ULL; + other.roles = 0; + other.level = 90; + update.members.push_back(other); + + update.dungeonEntries.push_back(0x010002CDu); + + WorldPacket packet(SMSG_LFG_ROLE_CHECK_UPDATE, expected.size()); + MopLfgPackets::BuildRoleCheckUpdate(packet, update); + + AssertBytes(packet, expected, "two_member_role_check"); + } + + /// capture-000059 seq 719547, 68 bytes. + /// + /// A five-man role check, and the case that proves partyIndex is a real field: it + /// carries 1, not 0. The leader has answered 0x03 (LEADER|TANK); the other four + /// have not. One dungeon, entry 0x060001CE -- type 6, a different dungeon type from + /// the case above. + void test_five_member_role_check() + { + std::vector const expected = { + 0x01, 0x02, 0x00, 0x00, 0x2F, 0x71, 0xB8, 0xD8, 0x6E, 0x37, 0x00, + 0x00, 0x00, 0x40, 0x80, + 0x5A, 0x04, 0x03, 0x00, 0x00, 0x00, 0xFF, 0xD5, 0x4D, 0x05, + 0x5A, 0x04, 0x00, 0x00, 0x00, 0x00, 0x39, 0x4D, 0xDF, 0x05, + 0x5A, 0x04, 0x00, 0x00, 0x00, 0x00, 0x4B, 0xB1, 0x05, 0x5A, + 0x07, 0x00, 0x00, 0x00, 0x00, 0x54, 0x59, 0x6A, 0x07, + 0x5A, 0x04, 0x00, 0x00, 0x00, 0x00, 0x3B, 0xA9, 0x57, 0x05, + 0xCE, 0x01, 0x00, 0x06 + }; + + MopLfgPackets::RoleCheckUpdate update; + update.partyIndex = 1; + update.state = LFG_ROLECHECK_INITIALITING; + + uint64 const guids[5] = { + 0x0400000005FE4CD4ULL, + 0x040000000538DE4CULL, + 0x04000000054A00B0ULL, + 0x0600000006556B58ULL, + 0x04000000053A56A8ULL + }; + uint32 const roles[5] = { 0x03, 0, 0, 0, 0 }; + + for (size_t i = 0; i < 5; ++i) + { + MopLfgPackets::RoleCheckMember member; + member.guid = guids[i]; + member.roles = roles[i]; + member.level = 90; + update.members.push_back(member); + } + + update.dungeonEntries.push_back(0x060001CEu); + + WorldPacket packet(SMSG_LFG_ROLE_CHECK_UPDATE, expected.size()); + MopLfgPackets::BuildRoleCheckUpdate(packet, update); + + AssertBytes(packet, expected, "five_member_role_check"); + } +} + +int main() +{ + test_two_member_role_check(); + test_five_member_role_check(); + + std::printf("mop_lfg_role_check_packets_test: OK\n"); + return 0; +} diff --git a/src/game/Server/tests/mop_lfg_set_roles_packets_test.cpp b/src/game/Server/tests/mop_lfg_set_roles_packets_test.cpp new file mode 100644 index 000000000..cb706a21d --- /dev/null +++ b/src/game/Server/tests/mop_lfg_set_roles_packets_test.cpp @@ -0,0 +1,90 @@ +/** + * Byte-exact coverage for the CMSG_LFG_SET_ROLES (0x08A2) reader. + * + * The bodies below are REAL captured client bytes at build 18414, not inverses of our + * own writer. Provenance is recorded per case so the fixture can be re-derived. + * + * Corpus catalogueGenerationId + * 2BE10C899585BAECD237705AC13BBF9262D81B6BDC085B462808C6869CE88752 + * + * Layout derived from the client's body writer sub_6688D0, reached as vtable slot 1 + * behind the opcode thunk sub_6615FE (which writes 2210): + * + * sub_40F075(pkt, *(uint32*)(this + 16)); // WriteUInt32 -- role mask + * sub_40F018(pkt, *(uint8 *)(this + 20)); // WriteUInt8 -- role check counter + * + * Flat, so there is no bit packing and no GUID obfuscation to undo. + */ + +#include "Group.h" +#include "WorldPacket.h" + +#include +#include +#include + +namespace +{ + WorldPacket MakeBody(std::vector const& bytes) + { + WorldPacket packet(CMSG_LFG_SET_ROLES, bytes.size()); + packet.append(bytes.data(), bytes.size()); + return packet; + } + + /// capture-000086 seq 16621, 5 bytes: 08 00 00 00 00 + /// A plain damage-only selection -- the single-role case. + void test_single_role_body() + { + std::vector const body = { 0x08, 0x00, 0x00, 0x00, 0x00 }; + + WorldPacket packet = MakeBody(body); + MopLfgSetRolesPackets::Request request; + assert(MopLfgSetRolesPackets::ParseRequest(packet, request)); + + assert(request.roles == 0x08); // PLAYER_ROLE_DAMAGE + assert(request.roleCheckCounter == 0); + assert(packet.rpos() == packet.size()); // no tail left unread + } + + /// capture-000112 seq 90341, 5 bytes: 0A 00 00 00 00 + /// + /// The case that matters: 0x0A is TANK|DAMAGE, one player offering either role. This + /// is direct wire proof that the mask is a bitmask and not an enum -- the reader must + /// not try to match it against a single role value. + void test_hybrid_role_body() + { + std::vector const body = { 0x0A, 0x00, 0x00, 0x00, 0x00 }; + + WorldPacket packet = MakeBody(body); + MopLfgSetRolesPackets::Request request; + assert(MopLfgSetRolesPackets::ParseRequest(packet, request)); + + assert(request.roles == 0x0A); + assert((request.roles & PLAYER_ROLE_TANK) != 0); + assert((request.roles & PLAYER_ROLE_DAMAGE) != 0); + assert((request.roles & PLAYER_ROLE_HEALER) == 0); + assert(request.roleCheckCounter == 0); + assert(packet.rpos() == packet.size()); + } + + /// A body one byte short must be refused outright rather than read past its end. + void test_short_body_is_refused() + { + std::vector const body = { 0x08, 0x00, 0x00, 0x00 }; + + WorldPacket packet = MakeBody(body); + MopLfgSetRolesPackets::Request request; + assert(!MopLfgSetRolesPackets::ParseRequest(packet, request)); + } +} + +int main() +{ + test_single_role_body(); + test_hybrid_role_body(); + test_short_body_is_refused(); + + std::printf("mop_lfg_set_roles_packets_test: OK\n"); + return 0; +} diff --git a/src/game/WorldHandlers/Chat.cpp b/src/game/WorldHandlers/Chat.cpp index 257d95aa7..34cb62787 100644 --- a/src/game/WorldHandlers/Chat.cpp +++ b/src/game/WorldHandlers/Chat.cpp @@ -277,6 +277,7 @@ ChatCommand* ChatHandler::getCommandTable() { "anim", SEC_GAMEMASTER, false, &ChatHandler::HandleDebugAnimCommand, "", NULL }, { "arena", SEC_ADMINISTRATOR, false, &ChatHandler::HandleDebugArenaCommand, "", NULL }, { "bg", SEC_ADMINISTRATOR, false, &ChatHandler::HandleDebugBattlegroundCommand, "", NULL }, + { "dungeon", SEC_ADMINISTRATOR, false, &ChatHandler::HandleDebugDungeonCommand, "", NULL }, { "getitemstate", SEC_ADMINISTRATOR, false, &ChatHandler::HandleDebugGetItemStateCommand, "", NULL }, { "lootrecipient", SEC_GAMEMASTER, false, &ChatHandler::HandleDebugGetLootRecipientCommand, "", NULL }, { "losdebug", SEC_GAMEMASTER, false, &ChatHandler::HandleDebugLosCommand, "", NULL }, diff --git a/src/game/WorldHandlers/Chat.h b/src/game/WorldHandlers/Chat.h index eb5155189..943a48212 100644 --- a/src/game/WorldHandlers/Chat.h +++ b/src/game/WorldHandlers/Chat.h @@ -586,6 +586,7 @@ class ChatHandler bool HandleDebugAnimCommand(char* args); bool HandleDebugArenaCommand(char* args); bool HandleDebugBattlegroundCommand(char* args); + bool HandleDebugDungeonCommand(char* args); bool HandleDebugGetItemStateCommand(char* args); bool HandleDebugGetItemValueCommand(char* args); bool HandleDebugGetLootRecipientCommand(char* args); diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index f826063e9..4f28a6cb9 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -54,6 +54,7 @@ #include "ObjectMgr.h" #include "ObjectGuid.h" #include "Group.h" +#include "LFGMgr.h" #include "Formulas.h" #include "ObjectAccessor.h" #include "BattleGround/BattleGround.h" @@ -603,6 +604,116 @@ bool MopGroupPromotePackets::ParseAssistant(WorldPacket& in, AssistantRequest& o return true; } +bool MopLfgProposalResponsePackets::ParseRequest(WorldPacket& in, Request& out) +{ + // 16 flat bytes, then 17 bits (1 accept + 16 GUID mask), then up to 16 GUID bytes. + // The minimum body is therefore 16 + 3 = 19 bytes with both GUIDs entirely zero. + if (in.size() - in.rpos() < 19) + { + return false; + } + + in >> out.proposalId; + in >> out.clientQueueId; + in >> out.flags; + in >> out.joinTime; + + out.accepted = in.ReadBit(); + + uint8 maskA[8] = { 0 }; + uint8 maskB[8] = { 0 }; + + // Mask order straight off the writer: A6 A0 A2 A4 B6 B7 A3 B4 A7 B1 A5 B0 A1 B2 B3 B5. + uint8* const maskOrder[16] = + { + &maskA[6], &maskA[0], &maskA[2], &maskA[4], &maskB[6], &maskB[7], + &maskA[3], &maskB[4], &maskA[7], &maskB[1], &maskA[5], &maskB[0], + &maskA[1], &maskB[2], &maskB[3], &maskB[5] + }; + + for (size_t i = 0; i < 16; ++i) + { + *maskOrder[i] = in.ReadBit() ? 1 : 0; + } + + uint8 bytesA[8] = { 0 }; + uint8 bytesB[8] = { 0 }; + + // Byte order, again off the writer: A3 A6 A4 A1 B7 B0 A7 B6 A5 B3 B1 B5 B4 A0 A2 B2. + struct Slot { uint8 const* mask; uint8* value; }; + Slot const byteOrder[16] = + { + { &maskA[3], &bytesA[3] }, { &maskA[6], &bytesA[6] }, + { &maskA[4], &bytesA[4] }, { &maskA[1], &bytesA[1] }, + { &maskB[7], &bytesB[7] }, { &maskB[0], &bytesB[0] }, + { &maskA[7], &bytesA[7] }, { &maskB[6], &bytesB[6] }, + { &maskA[5], &bytesA[5] }, { &maskB[3], &bytesB[3] }, + { &maskB[1], &bytesB[1] }, { &maskB[5], &bytesB[5] }, + { &maskB[4], &bytesB[4] }, { &maskA[0], &bytesA[0] }, + { &maskA[2], &bytesA[2] }, { &maskB[2], &bytesB[2] } + }; + + // Bound the read before touching it: a truncated body must be refused, not read + // past its end. + size_t present = 0; + for (size_t i = 0; i < 16; ++i) + { + if (*byteOrder[i].mask) + { + ++present; + } + } + + if (in.size() - in.rpos() < present) + { + return false; + } + + for (size_t i = 0; i < 16; ++i) + { + if (*byteOrder[i].mask) + { + uint8 value = 0; + in >> value; + *byteOrder[i].value = uint8(value ^ 1); // WriteByteSeq obfuscation + } + } + + uint64 rawA = 0; + uint64 rawB = 0; + for (size_t i = 0; i < 8; ++i) + { + rawA |= uint64(bytesA[i]) << (i * 8); + rawB |= uint64(bytesB[i]) << (i * 8); + } + + out.guidA = ObjectGuid(rawA); + out.guidB = ObjectGuid(rawB); + + // Every byte must be accounted for. Leftover tail means the mask was misread and + // the GUIDs are wrong, which is worse than refusing the packet. + return in.rpos() == in.size(); +} + +bool MopLfgSetRolesPackets::ParseRequest(WorldPacket& in, Request& out) +{ + // Fixed 5 bytes. Refuse anything else rather than reading past the end -- a short + // body would otherwise leave the role mask half-populated and silently queue the + // player as the wrong role. + if (in.size() - in.rpos() < 5) + { + return false; + } + + in >> out.roles; + in >> out.roleCheckCounter; + + // The body is exactly 5 bytes. A longer one is not this packet, and accepting it + // would leave unread tail data -- the cheapest signal there is that a body was read + // wrongly, so it must not be swallowed silently. + return in.rpos() == in.size(); +} + bool MopLfgLeavePackets::ParseRequest(WorldPacket& in, Request& out) { // Build 18414 writer sub_6674C9 (Wow.exe.c:879339-879394). Layout: @@ -1612,6 +1723,14 @@ void Group::Disband(bool hideDestroy) { CompleteReadyCheck(); + // Release the LFG status here rather than when the dungeon finishes: while the Group + // still reports GROUPTYPE_LFD, SendUpdate needs the status to fill the LFG block, and + // a missing status makes it emit a zero dungeon slot. + if (isLFGGroup()) + { + sLFGMgr.ReleaseGroupLfgStatus(GetObjectGuid()); + } + Player* player; for (member_citerator citr = m_memberSlots.begin(); citr != m_memberSlots.end(); ++citr) @@ -2502,7 +2621,51 @@ void Group::SendUpdateToPlayer(ObjectGuid guid) update.lootMethod = uint8(m_lootMethod); update.lootThreshold = uint8(m_lootThreshold); update.isLfg = isLFGGroup(); + if (update.isLfg) + { + // The LFG block has TWO dungeon slots and they are not interchangeable. + // + // Slot A (lfgDungeonEntry) is the gating one: it is what the client copies to + // party+232, which is precisely what IsPartyLFG() tests and GetPartyLFGID() + // returns, and every UI gate for the minimap eye and the Leave Dungeon entries + // runs through it. It carries type 1 in all 8475 retail packets whose block is + // populated -- never type 6 -- so it must be the RESOLVED dungeon. Ours is, + // because LFGGroupStatus records the concrete dungeon CreateDungeonGroup ran. + // + // Slot B (lfgTail) carries the random category, type 6, and is 0 for a direct + // queue. Worked example, capture-000044 seq 6287, block at payload offset 0x6E: + // 00 00 80 3F | 01 | 00 | 88 00 00 01 | 00 00 04 | 03 01 00 06 + // float=1.0 | b0 | b1 | A=0x01000088 | b2 b3 b4 | B=0x06000103 + // An earlier revision of this comment cited 03 01 00 06 as evidence for slot A. + // Those bytes are slot B. The code was right and the citation was not. + // + // Sending isLfg with a zero A is WORSE than sending no block: the client copies + // it, party+232 becomes 0, and IsPartyLFG() is then false -- indistinguishable + // from having no LFG party at all. + update.lfgDungeonEntry = sLFGMgr.GetGroupDungeonEntry(GetObjectGuid()); + update.lfgTail = sLFGMgr.GetGroupRandomDungeonEntry(GetObjectGuid()); + + // b0 is the LFG state, and the client reads bit 0x02 of it as IsLFGComplete() + // (sub_90261A: *(party+228) & 2). Retail flips it 1 -> 2 at DUNGEON_FINISHED + // (capture-000720 seq 1074 -> 46476). We sent 0 always, so IsLFGComplete() was + // permanently false and UIParent.lua:4176's `IsPartyLFG() and not IsLFGComplete()` + // always fired the deserter warning on leaving. + LFGState const lfgState = sLFGMgr.GetGroupLfgState(GetObjectGuid()); + update.lfgUnknownByte0 = (lfgState == LFG_STATE_FINISHED_DUNGEON) ? 2 : 1; + + // b4 tracks the member count, observed as n-1 in 5192 of the sampled rows. + update.lfgUnknownByte4 = m_memberSlots.empty() ? 0 : uint8(m_memberSlots.size() - 1); + } + // Retail's LFG groups send groupType 0x0C, i.e. GROUPTYPE_LFD (0x08) plus 0x04. + // Bit 0x04 is what the client returns from HasLFGRestrictions() (sub_9025EA reads + // party+216 & 4). We stored and sent 0x08 alone, so every LFG group reported having + // no restrictions. Set on the WIRE value only -- m_groupType is persisted and used + // in server-side logic, and widening the stored enum would change both. update.groupType = uint8(m_groupType); + if (update.isLfg) + { + update.groupType |= 0x04; + } update.partyIndex = player->GetOriginalGroup() == this ? 0 : uint8(isBGGroup() || isLFGGroup()); update.sequence = m_groupUpdateCounter; diff --git a/src/game/WorldHandlers/Group.h b/src/game/WorldHandlers/Group.h index 2067b7a30..adc6e51f6 100644 --- a/src/game/WorldHandlers/Group.h +++ b/src/game/WorldHandlers/Group.h @@ -333,6 +333,60 @@ namespace MopLfgLeavePackets bool ParseRequest(WorldPacket& in, Request& out); } +namespace MopLfgSetRolesPackets +{ + /// A parsed CMSG_LFG_SET_ROLES body. + /// + /// Derived from the client's own body writer sub_6688D0 -- vtable slot 1 behind the + /// opcode thunk sub_6615FE, which writes 2210 (0x08A2). It emits exactly two fields + /// and nothing else: + /// + /// sub_40F075(pkt, *(uint32*)(this + 16)); // WriteUInt32 -- the role mask + /// sub_40F018(pkt, *(uint8 *)(this + 20)); // WriteUInt8 -- role check counter + /// + /// Flat: no bit packing and no GUID, so there is nothing to XOR or reorder. All 99 + /// build-18414 captures in the corpus are exactly 5 bytes, which agrees. + struct Request + { + /// Bitmask, NOT an enum. The LFD frame's four checkboxes are independent, so a + /// player offering tank-or-damage sends 0x0A -- observed on the wire in + /// capture-000112 seq 90341. + uint32 roles = 0; + + /// Echoed back by the client; carries no authority server-side. + uint8 roleCheckCounter = 0; + }; + + bool ParseRequest(WorldPacket& in, Request& out); +} + +namespace MopLfgProposalResponsePackets +{ + /// A parsed CMSG_LFG_PROPOSAL_RESPONSE body. + /// + /// Derived from the client's own body writer sub_66A29E -- vtable slot 1 behind the + /// opcode thunk sub_6622E8, which writes 7581 (0x1D9D). GUID A lives at this+24..31 + /// and GUID B at this+48..55. + /// + /// Everything except `accepted` is an echo of the SMSG_LFG_PROPOSAL_UPDATE the + /// server sent: capture-000059 seq 2063770 echoes proposalId 11132, clientQueueId + /// 37743, flags 3 and joinTime 1409232359 straight back from seq 2063424 in the same + /// capture, along with both GUIDs. None of it is authority -- the server answers on + /// behalf of the CALLER and keys on its own proposal id. + struct Request + { + ObjectGuid guidA; // the sender's group, or the sender + ObjectGuid guidB; // instance-side GUID; echoed, never trusted + uint32 proposalId = 0; + uint32 clientQueueId = 0; + uint32 flags = 0; + uint32 joinTime = 0; + bool accepted = false; + }; + + bool ParseRequest(WorldPacket& in, Request& out); +} + namespace MopGroupMarkerPackets { struct MinimapPingRequest diff --git a/src/game/WorldHandlers/GroupHandler.cpp b/src/game/WorldHandlers/GroupHandler.cpp index 5b753b4e3..3d4c26cae 100644 --- a/src/game/WorldHandlers/GroupHandler.cpp +++ b/src/game/WorldHandlers/GroupHandler.cpp @@ -53,6 +53,7 @@ #include "Player.h" #include "SpellAuras.h" #include "Group.h" +#include "LFGMgr.h" #include "SocialMgr.h" #include "Util.h" #include "DB2Structure.h" @@ -471,9 +472,18 @@ void WorldSession::HandleGroupSetLeaderOpcode(WorldPacket& recv_data) * * @param recv_data The received opcode packet. */ -void WorldSession::HandleGroupDisbandOpcode(WorldPacket& /*recv_data*/) +void WorldSession::HandleGroupDisbandOpcode(WorldPacket& recv_data) { - if (!GetPlayer()->GetGroup()) + // One byte, observed 0x7F. It carries no authority -- the server acts on the caller -- + // but it must be consumed or the dispatcher logs "unprocessed tail data" on every + // Leave Instance Group click. + if (recv_data.size() - recv_data.rpos() >= 1) + { + recv_data.read_skip(); + } + + Group* pGroup = GetPlayer()->GetGroup(); + if (!pGroup) { return; } @@ -487,6 +497,23 @@ void WorldSession::HandleGroupDisbandOpcode(WorldPacket& /*recv_data*/) /** error handling **/ /********************/ + // Leaving an LFG group from INSIDE its dungeon has to put the player back where + // they came from. Retail answers the disband with SMSG_GROUP_LIST (the 40-byte + // no-group form), SMSG_TRANSFER_PENDING (mapId 0) and SMSG_NEW_WORLD + // (capture-000720 seq 46746, capture-000656 seq 191821). + // + // Without this the player simply stood in the instance, group gone, and was only + // collected 60 seconds later by the homebind timer that fires when the instance + // stops being valid for them. Observed live: "leave dungeon did not relocate me". + // + // Must run BEFORE RemoveFromGroup -- TeleportPlayer resolves the dungeon through the + // group's LFG status, which is gone once the group is. It is a no-op unless the + // player is actually standing on the dungeon's map. + if (pGroup->isLFGGroup()) + { + sLFGMgr.TeleportPlayer(GetPlayer(), true); + } + // everything is fine, do it SendPartyResult(PARTY_OP_LEAVE, GetPlayer()->GetName(), ERR_PARTY_RESULT_OK); diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index cdf777863..f7fad71c1 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -146,11 +146,9 @@ void WorldSession::HandleLfgJoinOpcode(WorldPacket& recv_data) // DifficultyID == 0 and no TypeID==1 row in LfgDungeons.dbc carries that, // so every entry reports needing nobody and any two would be matched. // - // Known gap, deliberately not hidden: SendLfgJoinResult builds - // SMSG_LFG_JOIN_RESULT, which is NOT admitted, so a REFUSED join tells the - // player nothing. Success is unaffected -- SendLfgUpdate goes out over the - // already-admitted SMSG_LFG_UPDATE_STATUS. The reply is held rather than - // admitted because the only fixture for its non-empty form is synthetic. + // SMSG_LFG_JOIN_RESULT is now built to the 18414 layout and admitted, so a + // refused join reaches the player. See MopLfgPackets::BuildJoinResult for the + // three captures it is pinned to. std::set requested(dungeons.begin(), dungeons.end()); sLFGMgr.JoinLFG(roles, requested, comment, GetPlayer()); } @@ -176,12 +174,87 @@ void WorldSession::HandleLfgLeaveOpcode(WorldPacket& recv_data) return; } - // A grouped player leaves on behalf of the group, which is how the queue - // stores it -- JoinLFG keys group entries by the GROUP guid. + // A grouped player leaves on behalf of the group, which is how the queue stores it + // -- JoinLFG keys group entries by the GROUP guid. + // + // The test used to be `pGroup && pGroup->IsLeader(...)`, which sent a non-leader + // down the SOLO branch. That branch erases m_playerData[playerGuid], and for a + // grouped queuer no such entry exists: the party's real entry, keyed by the group + // guid, was left in the queue untouched while the client was told it had left. + // Whether a non-leader may cancel for the party is a permission question, answered + // in LeaveLFG, not a reason to cancel the wrong thing. + Group* pGroup = plr->GetGroup(); + + sLFGMgr.LeaveLFG(plr, pGroup != nullptr); +} + +void WorldSession::HandleLfgSetRolesOpcode(WorldPacket& recv_data) +{ + DEBUG_LOG("CMSG_LFG_SET_ROLES"); + + // This is the reply half of the LFG role check, and it had no handler and no + // registration at all -- the client's answer was dropped at the dispatcher without + // so much as a log line, so a party entered LFG_STATE_ROLECHECK and stayed there. + // + // Note the Lua SetLFGRoles() does NOT send this; it only mutates local state. The + // packet is emitted by CompleteLFGRoleCheck, i.e. when the player confirms. + MopLfgSetRolesPackets::Request request; + if (!MopLfgSetRolesPackets::ParseRequest(recv_data, request)) + { + sLog.outError("Malformed CMSG_LFG_SET_ROLES body from %s: expected 5 bytes.", + GetPlayerName()); + return; + } + + Player* plr = GetPlayer(); + if (!plr) + { + return; + } + + // A role check only exists for a party. A solo queuer states their roles in + // CMSG_LFG_JOIN and never reaches this path. Group* pGroup = plr->GetGroup(); - bool const isGroup = pGroup && pGroup->IsLeader(plr->GetObjectGuid()); + if (!pGroup) + { + return; + } + + DEBUG_LOG("CMSG_LFG_SET_ROLES: %s roles 0x%02X.", GetPlayerName(), request.roles); + + // Truncated to the byte the role plumbing uses. The wire field is 32 bits, but only + // the low four (leader/tank/healer/damage) are ever set; anything above them is + // rejected by PerformRoleCheck's mask test rather than being silently accepted. + sLFGMgr.PerformRoleCheck(plr, pGroup, uint8(request.roles & 0xFF)); +} + +void WorldSession::HandleLfgProposalResponseOpcode(WorldPacket& recv_data) +{ + DEBUG_LOG("CMSG_LFG_PROPOSAL_RESPONSE"); + + // Without this a proposal could be built and sent but never answered -- the accept + // and decline buttons both did nothing, because the reply was dropped at the + // dispatcher with no handler and no registration. + MopLfgProposalResponsePackets::Request request; + if (!MopLfgProposalResponsePackets::ParseRequest(recv_data, request)) + { + sLog.outError("Malformed CMSG_LFG_PROPOSAL_RESPONSE body from %s.", GetPlayerName()); + return; + } + + Player* plr = GetPlayer(); + if (!plr) + { + return; + } - sLFGMgr.LeaveLFG(plr, isGroup); + DEBUG_LOG("CMSG_LFG_PROPOSAL_RESPONSE: %s %s proposal %u.", + GetPlayerName(), request.accepted ? "accepted" : "declined", request.proposalId); + + // Answer on behalf of the CALLER, keyed on our own proposal id. The GUIDs and the + // queue triplet in the body are echoes of what we sent and carry no authority; a + // client that returns a different guidA must not be able to answer for someone else. + sLFGMgr.ProposalUpdate(request.proposalId, plr->GetObjectGuid(), request.accepted); } void WorldSession::HandleLfgGetStatusOpcode(WorldPacket& /*recv_data*/) @@ -192,12 +265,50 @@ void WorldSession::HandleLfgGetStatusOpcode(WorldPacket& /*recv_data*/) if (status.state == LFG_STATE_NONE) return; + // Exactly ONE packet, with the dungeon list PRESENT. + // + // This used to send a second copy with dungeonList.clear(). No such body exists in + // retail traffic: 0 of 5291 observed SMSG_LFG_UPDATE_STATUS carry an empty dungeon + // list. It was the old 3.3.5 UPDATE_PARTY/UPDATE_PLAYER pair, and 5.4.8 has a + // single opcode. Retail's reply to the zone-in probe is one reason-15 body that + // still lists the dungeons (capture-000720 seq 1286, reproduced at capture-000044 + // seq 6354, capture-000656 seq 113708 and capture-000872 seq 14299). + // + // The LFG_STATE_NONE early return above is also correct and must stay: 1598 of 2144 + // GET_STATUS probes draw no reply at all, and none of the 504 post-completion or + // post-leave probes do. status.updateType = LFG_UPDATE_STATUS; - bool const groupFirst = GetPlayer()->GetGroup() != nullptr; - SendLfgUpdate(groupFirst, status); + SendLfgUpdate(GetPlayer()->GetGroup() != nullptr, status); +} + +void WorldSession::HandleLfgTeleportOpcode(WorldPacket& recv_data) +{ + DEBUG_LOG("CMSG_LFG_TELEPORT"); + + // The body is ONE BIT, MSB-first, not a uint8. All 47 corpus events are a single + // byte carrying only 0x80 or 0x00, and the destination map of the SMSG_TRANSFER_PENDING + // that follows classifies them: 0x80 precedes a move to an outdoor map (0, 530, 571, + // 870, 974) and 0x00 precedes a move to an instance (70, 547, 556, 558, 574, 575, + // 599, 600, 960, 1004, 1098, 1136). So 0x80 is OUT and 0x00 is back IN -- 0x00 is not + // a leave. capture-000059 seqs 1038789..1040642 are all 0x00 with prevMap 960 and + // destMap 960, i.e. re-summons into the same instance. + // + // A reader switching on 0 and 1 would match neither value. + if (recv_data.size() - recv_data.rpos() != 1) + { + sLog.outError("WORLD: malformed CMSG_LFG_TELEPORT from %s", GetPlayerName()); + return; + } + + bool const out = recv_data.ReadBit(); + + Player* plr = GetPlayer(); + if (!plr) + { + return; + } - status.dungeonList.clear(); - SendLfgUpdate(!groupFirst, status); + sLFGMgr.TeleportPlayer(plr, out); } void WorldSession::HandleLfgLockInfoRequestOpcode(WorldPacket& recv_data) @@ -218,10 +329,42 @@ void WorldSession::HandleLfgLockInfoRequestOpcode(WorldPacket& recv_data) void WorldSession::SendLfgPlayerLockInfo() { - // The legacy LFG manager cannot express the 18414 random-dungeon reward - // records. Send the binary-proven empty shape instead of guessed fields. - WorldPacket data(SMSG_LFG_PLAYER_INFO, 5); - MopLfgPackets::BuildEmptyPlayerInfo(data); + Player* plr = GetPlayer(); + if (!plr) + { + return; + } + + // The eligibility data the client needs to grey out content it cannot enter. + // + // FindRandomDungeonsNotForPlayer already computes exactly this: a map keyed by + // LfgDungeonsEntry::Entry() -- which IS the wire's dungeonEntry field -- with an + // LFGForbiddenTypes value, and those codes are the client's LFG_INSTANCE_INVALID_CODES + // verbatim (2 LEVEL_TOO_LOW, 3 LEVEL_TOO_HIGH, 1025 MISSING_ITEM, 1031 NOT_IN_SEASON + // and so on). So no translation is required in either direction. + dungeonForbidden const locked = sLFGMgr.FindRandomDungeonsNotForPlayer(plr); + + std::vector locks; + locks.reserve(locked.size()); + + for (dungeonForbidden::const_iterator it = locked.begin(); it != locked.end(); ++it) + { + MopLfgPackets::PlayerLockInfo entry; + entry.dungeonEntry = it->first; + entry.lockStatus = it->second; + // subReason1/2 stay zero. They carry the required and current item level for the + // gear-score reasons; all 206 records of the reference capture have them zero. + locks.push_back(entry); + } + + // 5-byte header plus 16 bytes per lock. The reference reply was 6068 bytes for 206 + // locks and 35 random records; ours is locks-only, so 5 + 16 * n. + WorldPacket data(SMSG_LFG_PLAYER_INFO, 5 + locks.size() * 16); + MopLfgPackets::BuildPlayerInfo(data, locks); + + DEBUG_LOG("SMSG_LFG_PLAYER_INFO: %s, %u locked dungeon(s), %u bytes.", + GetPlayerName(), uint32(locks.size()), uint32(data.size())); + SendPacket(&data); } @@ -242,35 +385,47 @@ void WorldSession::HandleSetLfgCommentOpcode(WorldPacket& recv_data) DEBUG_LOG("LFG comment \"%s\"", comment.c_str()); } -void WorldSession::SendLfgJoinResult(LfgJoinResult result, LFGState state, partyForbidden const& lockedDungeons) +void WorldSession::SendLfgJoinResult(LfgJoinResult result, uint8 detail, partyForbidden const& lockedDungeons) { - uint32 packetSize = 0; - for (partyForbidden::const_iterator it = lockedDungeons.begin(); it != lockedDungeons.end(); ++it) + MopLfgPackets::JoinResult update; + update.result = uint8(result); + update.detail = detail; + + // Retail zeroes the GUID and the whole ticket on a refusal -- that is what makes the + // 18-byte form -- and carries both on a success. All 11 observed refusals are the + // zeroed shape, so a refusal must not invent a ticket. + if (result == ERR_LFG_OK) { - packetSize += 12 + uint32(it->second.size()) * 8; + if (Player* player = GetPlayer()) + { + update.requesterGuid = player->GetObjectGuid().GetRawValue(); + } + LFGStatusPacketData queueData; + sLFGMgr.GetStatusPacketData(GetPlayer()->GetObjectGuid(), GetPlayer()->GetObjectGuid(), queueData); + update.joinTime = queueData.joinedTime ? queueData.joinedTime : uint32(time(NULL)); + update.clientQueueId = queueData.ticketId; + update.ticketType = 3; } - WorldPacket data(SMSG_LFG_JOIN_RESULT, packetSize); - data << uint32(result); - data << uint32(state); - - if (!lockedDungeons.empty()) + for (partyForbidden::const_iterator it = lockedDungeons.begin(); it != lockedDungeons.end(); ++it) { - for (partyForbidden::const_iterator it = lockedDungeons.begin(); it != lockedDungeons.end(); ++it) - { - dungeonForbidden dungeonInfo = it->second; + MopLfgPackets::JoinResultPlayer player; + player.guid = it->first.GetRawValue(); - data << uint64(it->first); // object guid of player - data << uint32(dungeonInfo.size()); // amount of their locked dungeons - - for (dungeonForbidden::iterator itr = dungeonInfo.begin(); itr != dungeonInfo.end(); ++itr) - { - data << uint32(itr->first); // dungeon entry - data << uint32(itr->second); // reason for dungeon being forbidden/locked - } + for (dungeonForbidden::const_iterator itr = it->second.begin(); itr != it->second.end(); ++itr) + { + MopLfgPackets::PlayerLockInfo lock; + lock.dungeonEntry = itr->first; + lock.lockStatus = itr->second; + player.locks.push_back(lock); } + + update.players.push_back(player); } + WorldPacket data(SMSG_LFG_JOIN_RESULT, 24); + MopLfgPackets::BuildJoinResult(data, update); + SendPacket(&data); } @@ -289,9 +444,20 @@ void WorldSession::SendLfgUpdate(bool isGroup, LFGPlayerStatus status) case LFG_UPDATE_PROPOSAL_BEGIN: joined = true; break; + case LFG_UPDATE_JOIN_QUEUE_INITIAL: + joined = true; + break; case LFG_UPDATE_STATUS: isQueued = (status.state == LFG_STATE_QUEUED); - joined = status.state != LFG_STATE_NONE; + // `joined` must go FALSE once the player is inside. It used to be + // `state != LFG_STATE_NONE`, and LFG_STATE_IN_DUNGEON is non-zero, so we + // reported joined=1 from inside the dungeon where retail sends 0 + // (capture-000720 seq 1286, byte 1 = 0x80). UIParent.lua:3902 GetLFGMode then + // returns "suspended" instead of falling through to "lfgparty" -- the client + // believes the player is still queued rather than in the run. + joined = (status.state != LFG_STATE_NONE + && status.state != LFG_STATE_IN_DUNGEON + && status.state != LFG_STATE_FINISHED_DUNGEON); break; default: break; @@ -308,15 +474,27 @@ void WorldSession::SendLfgUpdate(bool isGroup, LFGPlayerStatus status) MopLfgPackets::StatusUpdate update; update.requesterGuid = queueGuid.GetRawValue(); update.comment = status.comment; - update.needs = {{ queueData.neededTanks, queueData.neededHealers, queueData.neededDps }}; - update.isParty = isGroup; + // Retail leaves these 0,0,0 in all 5291 observed bodies without exception; the + // role shortage is advertised in SMSG_LFG_QUEUE_STATUS instead. + update.needs = {{ 0, 0, 0 }}; + // Always 1. Across 5291 retail bodies byte 1 takes only 0x00, 0x80 and 0xC0 -- + // the 0x40 our solo queue used to emit (bit9 set, bit8 clear) occurs zero times, + // and bit8 is set even for a solo queue with no group at all. The name "isParty" + // does not explain that; the wire value is not in doubt. + update.isParty = true; update.joined = joined; - update.lfgJoined = status.updateType != LFG_UPDATE_LEAVE; + // notifyUi tracks joined -- equal in 5288 of 5291 bodies, and 0 for every terminal + // reason (8, 9, 11, 15, 25). It was defaulted true and never assigned. + update.notifyUi = joined; + // Not "did the player leave" and not "is the player inside": this bit says the + // queue entry is owned by a GROUP. All 1931 bodies with a group-typed requesterGuid + // carry it at every stage, including open-world queueing. It moves together with + // requesterGuid, which is exactly the condition that selected queueGuid above. + update.lfgJoined = (queueGuid != playerGuid); update.queued = isQueued; update.requestedRoles = queueData.roles; update.updateReason = uint8(status.updateType); - // This legacy single-queue manager does not track the client queue ID. - update.ticketId = 0; + update.ticketId = queueData.ticketId; update.ticketTime = queueData.joinedTime; if (!status.dungeonList.empty()) @@ -345,8 +523,10 @@ void WorldSession::SendLfgQueueStatus(LFGQueueStatus const& status) update.waitTimeDps = status.dpsAvgWaitTime; update.dps = status.neededDps; update.joinTime = status.joinTime; - // This legacy single-queue manager has no client queue-ID allocation. - update.clientQueueId = 0; + // Retail's clientQueueId IS the status packet's ticketId -- capture-000044 carries + // 0x9BFF in SMSG_LFG_JOIN_RESULT seq 1547, SMSG_LFG_QUEUE_STATUS seq 1577 and the + // status bodies alike. One identifier, three packets. + update.clientQueueId = status.ticketId; update.waitTime = status.playerAvgWaitTime; update.dungeonEntry = sLFGMgr.GetDungeonEntry(status.dungeonID); @@ -358,10 +538,12 @@ void WorldSession::SendLfgQueueStatus(LFGQueueStatus const& status) void WorldSession::SendLfgRoleCheckUpdate(LFGRoleCheck const& roleCheck) { - WorldPacket data(SMSG_LFG_ROLE_CHECK_UPDATE); - - data << uint32(roleCheck.state); - data << uint8(roleCheck.state == LFG_ROLECHECK_INITIALITING); + // Rebuilt for 18414. See MopLfgPackets::BuildRoleCheckUpdate for the layout and the + // two captures it was verified against; the previous body was the 3.3.5 shape and + // shared no field order with this client, which is why the role check prompt never + // appeared however correct the server-side state was. + MopLfgPackets::RoleCheckUpdate update; + update.state = uint8(roleCheck.state); std::set dungeons; if (roleCheck.randomDungeonID) @@ -373,43 +555,52 @@ void WorldSession::SendLfgRoleCheckUpdate(LFGRoleCheck const& roleCheck) dungeons = roleCheck.dungeonList; } - data << uint8(dungeons.size()); - if (!dungeons.empty()) - for (std::set::iterator it = dungeons.begin(); it != dungeons.end(); ++it) - { - data << uint32(sLFGMgr.GetDungeonEntry(*it)); - } + for (std::set::const_iterator it = dungeons.begin(); it != dungeons.end(); ++it) + { + update.dungeonEntries.push_back(sLFGMgr.GetDungeonEntry(*it)); + } - data << uint8(roleCheck.currentRoles.size()); - if (!roleCheck.currentRoles.empty()) + // The leader MUST be first: the client renders entry 0 as the initiator, and both + // captures show the leader's roles carrying the LEADER bit while later members are + // still zero. + ObjectGuid const leaderGuid = ObjectGuid(roleCheck.leaderGuidRaw); + + roleMap::const_iterator leaderItr = roleCheck.currentRoles.find(leaderGuid); + if (leaderItr != roleCheck.currentRoles.end()) { - ObjectGuid leaderGuid = ObjectGuid(roleCheck.leaderGuidRaw); - uint8 leaderRoles = roleCheck.currentRoles.find(leaderGuid)->second; + // Unchecked find() here previously: a role check whose leader had already left + // dereferenced end(). + MopLfgPackets::RoleCheckMember member; + member.guid = leaderGuid.GetRawValue(); + member.roles = leaderItr->second; + Player* pLeader = sObjectAccessor.FindPlayer(leaderGuid); + member.level = pLeader ? uint8(pLeader->getLevel()) : uint8(0); - data << uint64(leaderGuid.GetRawValue()); - data << uint8(leaderRoles > 0); - data << uint32(leaderRoles); - data << uint8(pLeader ? pLeader->getLevel() : 0); + update.members.push_back(member); + } - for (roleMap::const_iterator rItr = roleCheck.currentRoles.begin(); rItr != roleCheck.currentRoles.end(); ++rItr) + for (roleMap::const_iterator rItr = roleCheck.currentRoles.begin(); + rItr != roleCheck.currentRoles.end(); ++rItr) + { + if (rItr->first == leaderGuid) { - if (rItr->first == leaderGuid) - { - continue; // exclude the leader - } + continue; + } - ObjectGuid plrGuid = rItr->first; + MopLfgPackets::RoleCheckMember member; + member.guid = rItr->first.GetRawValue(); + member.roles = rItr->second; - Player* pPlayer = sObjectAccessor.FindPlayer(plrGuid); + Player* pPlayer = sObjectAccessor.FindPlayer(rItr->first); + member.level = pPlayer ? uint8(pPlayer->getLevel()) : uint8(0); - data << uint64(plrGuid.GetRawValue()); - data << uint8(rItr->second > 0); - data << uint32(rItr->second); - data << uint8(pPlayer ? pPlayer->getLevel() : 0); - } + update.members.push_back(member); } + WorldPacket data(SMSG_LFG_ROLE_CHECK_UPDATE, 16 + update.members.size() * 16); + MopLfgPackets::BuildRoleCheckUpdate(data, update); + SendPacket(&data); } @@ -425,52 +616,90 @@ void WorldSession::SendLfgRoleChosen(uint64 rawGuid, uint8 roles) void WorldSession::SendLfgProposalUpdate(LFGProposal const& proposal) { Player* pPlayer = GetPlayer(); - ObjectGuid plrGuid = pPlayer->GetObjectGuid(); - ObjectGuid plrGroupGuid = proposal.groups.find(plrGuid)->second; - - uint32 dungeonEntry = sLFGMgr.GetDungeonEntry(proposal.dungeonID); - bool showProposal = !proposal.isNew && proposal.groupRawGuid == plrGroupGuid.GetRawValue(); - - WorldPacket data(SMSG_LFG_PROPOSAL_UPDATE, 15 + (9 * proposal.currentRoles.size())); + if (!pPlayer) + { + return; + } - data << uint32(dungeonEntry); // Dungeon Entry - data << uint8(proposal.state); // Proposal state - data << uint32(proposal.id); // ID of proposal - data << uint32(proposal.encounters); // Encounters done - data << uint8(showProposal); // Show or hide proposal window [todo-this] - data << uint8(proposal.currentRoles.size()); // Size of group + ObjectGuid const plrGuid = pPlayer->GetObjectGuid(); - for (playerGroupMap::const_iterator it = proposal.groups.begin(); it != proposal.groups.end(); ++it) + // find() without checking end() dereferenced a past-the-end iterator here. It is + // reachable, not theoretical: SendDungeonProposal skips offline players when filling + // `groups` and `answers` but still lists them in `currentRoles`, so a player who + // queues, logs out and logs back in arrives with no entry of their own. + playerGroupMap::const_iterator myGroup = proposal.groups.find(plrGuid); + if (myGroup == proposal.groups.end()) { - ObjectGuid grpPlrGuid = it->first; - uint8 grpPlrRole = proposal.currentRoles.find(grpPlrGuid)->second; - LFGProposalAnswer grpPlrAnswer = proposal.answers.find(grpPlrGuid)->second; + return; + } - data << uint32(grpPlrRole); // Player's role - data << uint8(grpPlrGuid == plrGuid); // Is this player me? + ObjectGuid const plrGroupGuid = myGroup->second; + + // Rebuilt for 18414. See MopLfgPackets::BuildProposalUpdate for the layout and the + // two captures it was verified against. + MopLfgPackets::ProposalUpdate update; + update.dungeonEntry = sLFGMgr.GetDungeonEntry(proposal.dungeonID); + update.proposalId = proposal.id; + update.state = uint8(proposal.state); + update.encounters = proposal.encounters; + update.joinTime = uint32(proposal.joinedQueue); + + // "silent" suppresses opening a fresh window: the client updates one it already has. + // Only correct when this is not a new proposal AND the recipient is already in the + // group the proposal will reuse. + update.silent = !proposal.isNew && plrGroupGuid && + plrGroupGuid.GetRawValue() == proposal.groupRawGuid; + + // The recipient's own group if they have one, else themselves -- this identifies who + // the update is about, not the proposed group. + update.requesterGuid = plrGroupGuid ? plrGroupGuid.GetRawValue() : plrGuid.GetRawValue(); + + for (playerGroupMap::const_iterator it = proposal.groups.begin(); + it != proposal.groups.end(); ++it) + { + ObjectGuid const memberGuid = it->first; - if (it->second != 0) + roleMap::const_iterator roleItr = proposal.currentRoles.find(memberGuid); + proposalAnswerMap::const_iterator answerItr = proposal.answers.find(memberGuid); + if (roleItr == proposal.currentRoles.end() || answerItr == proposal.answers.end()) { - data << uint8(it->second == ObjectGuid(proposal.groupRawGuid)); // Is player in the proposed group? - data << uint8(it->second == plrGroupGuid); // Is player in the same group as myself? - } - else - { - data << uint8(0); - data << uint8(0); + continue; } - data << uint8(grpPlrAnswer != LFG_ANSWER_PENDING); // Has the player selected an answer? - data << uint8(grpPlrAnswer == LFG_ANSWER_AGREE); // Has the player agreed to do the dungeon? + MopLfgPackets::ProposalPlayer entry; + entry.roles = roleItr->second; + entry.isSelf = (memberGuid == plrGuid); + entry.answered = (answerItr->second != LFG_ANSWER_PENDING); + entry.agreed = (answerItr->second == LFG_ANSWER_AGREE); + entry.inProposedGroup = it->second && !proposal.isNew && + it->second.GetRawValue() == proposal.groupRawGuid; + entry.sameGroupAsSelf = it->second && it->second == plrGroupGuid; + + update.players.push_back(entry); } + + WorldPacket data(SMSG_LFG_PROPOSAL_UPDATE, 40 + update.players.size() * 5); + MopLfgPackets::BuildProposalUpdate(data, update); + SendPacket(&data); } void WorldSession::SendLfgTeleportError(uint8 error) { DEBUG_LOG("SMSG_LFG_TELEPORT_DENIED"); - WorldPacket data(SMSG_LFG_TELEPORT_DENIED, 4); - data << uint32(error); + + // One byte, not four. Every 18414 capture of this opcode in the corpus is exactly + // 1 byte (capture-000044 seq 70879 and 219256, capture-000465 seq 283035, + // capture-000628 seq 31349, capture-000873 seq 154730). + // + // NOT admitted by IsEnterWorldConverted, deliberately. The size is settled but the + // VALUE space is not: the one captured body carries 0x10 (16), while our + // LFGTeleportError enum stops at 8, so our codes are provably not the client's. + // Sending a correctly sized packet with a wrong code would show the player a + // confidently wrong reason, which is worse than the current silence. Admit this + // once the enum is derived from the client. + WorldPacket data(SMSG_LFG_TELEPORT_DENIED, 1); + data << uint8(error); SendPacket(&data); } diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 6b060f77f..f3fa8230c 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -23,6 +23,10 @@ * and lore are copyrighted by Blizzard Entertainment, Inc. */ +#include +#include +#include + #include "DBCEnums.h" #include "DBCStores.h" #include "DBCStructure.h" @@ -41,6 +45,8 @@ INSTANTIATE_SINGLETON_1(LFGMgr); LFGMgr::LFGMgr() { m_proposalId = 0; + // Starts at a non-zero base: retail never sends ticketId 0. + m_nextTicketId = 1000; } LFGMgr::~LFGMgr() @@ -75,6 +81,9 @@ void LFGMgr::Update() // remove old role checks RemoveOldRoleChecks(); + // and proposals nobody answered + RemoveOldProposals(); + // go through a waitTimeMap::iterator for each wait map and update times based on player count for (waitTimeMap::iterator tankItr = m_tankWaitTime.begin(); tankItr != m_tankWaitTime.end(); ++tankItr) { @@ -464,63 +473,205 @@ dungeonForbidden LFGMgr::FindRandomDungeonsNotForPlayer(Player* plr) return randomDungeons; } -void LFGMgr::UpdateNeededRoles(ObjectGuid guid, LFGPlayers* information) +namespace { - uint8 tankCount = 0, dpsCount = 0, healCount = 0; - for (roleMap::iterator it = information->currentRoles.begin(); it != information->currentRoles.end(); ++it) + // The client's LFD frame offers four INDEPENDENT checkboxes -- FrameXML/LFDFrame.lua + // calls SetLFGRoles(leader, tank, healer, dps) -- so the mask that arrives on the + // wire routinely carries several roles at once. A player who ticked tank AND dps is + // willing to fill either, not neither. + // + // Every consumer here used to switch on the exact value of (mask & ~LEADER), matching + // only 0x02/0x04/0x08. A hybrid therefore counted as zero of everything: solo hybrids + // merged into a full-size entry that still reported every role missing and could + // neither complete nor merge again, and a premade containing one hybrid failed its + // role check outright and was ejected. + // + // Assigning each player exactly one of the roles they offered needs backtracking, not + // a greedy pass: given a tank-only player and a tank-or-healer player, handing the + // tank slot to the hybrid first strands the specialist even though a valid assignment + // exists. + // + // The search is MEMOISED, and that is not an optimisation. Plain backtracking is + // exponential in the number of players, and this is not a five-man-only path: raid + // finder rows ask for 2/6/17 and flexible raid for 0/0/25, so a 25-player entry of + // hybrids would explore on the order of 3^25 states and hang the world thread -- + // LFGMgr::Update runs on it. Keying failures on (index, remaining quota) collapses + // that to at most (players+1) x (tank+1) x (healer+1) x (damage+1) states, a few + // thousand even for the largest shipped composition. + struct RoleQuota + { + uint8 tank; + uint8 healer; + uint8 damage; + + uint32 Total() const { return uint32(tank) + healer + damage; } + }; + + /// Pack (index, remaining quota) into one key for the failure memo. + uint64 RoleStateKey(size_t index, RoleQuota const& remaining) { - uint8 withoutLeader = it->second; - withoutLeader &= ~PLAYER_ROLE_LEADER; + return (uint64(index) << 24) + | (uint64(remaining.tank) << 16) + | (uint64(remaining.healer) << 8) + | uint64(remaining.damage); + } - switch (withoutLeader) + bool AssignRolesRecursive(std::vector const& masks, size_t index, RoleQuota remaining, + RoleQuota& leftover, std::set& deadEnds) + { + if (index == masks.size()) { - case PLAYER_ROLE_TANK: - ++tankCount; - break; - case PLAYER_ROLE_HEALER: - ++healCount; - break; - case PLAYER_ROLE_DAMAGE: - ++dpsCount; - break; + leftover = remaining; // what is still open once everyone present is placed + return true; + } + + // Already proved unsatisfiable from this exact state. + uint64 const key = RoleStateKey(index, remaining); + if (deadEnds.find(key) != deadEnds.end()) + { + return false; + } + + static uint8 const candidates[3] = { PLAYER_ROLE_TANK, PLAYER_ROLE_HEALER, PLAYER_ROLE_DAMAGE }; + + for (uint8 i = 0; i < 3; ++i) + { + uint8 const role = candidates[i]; + if (!(masks[index] & role)) + { + continue; + } + + uint8* slot = (role == PLAYER_ROLE_TANK) ? &remaining.tank + : (role == PLAYER_ROLE_HEALER) ? &remaining.healer + : &remaining.damage; + + if (!*slot) + { + continue; + } + + --(*slot); + if (AssignRolesRecursive(masks, index + 1, remaining, leftover, deadEnds)) + { + return true; + } + ++(*slot); } + + deadEnds.insert(key); + return false; } - std::set::iterator itr = information->dungeonList.begin(); + /// Can every player fill exactly one of the roles they offered, within the dungeon's caps? + /// On success `leftover` receives the roles still open, which is what the queue + /// advertises as "needed" and what the completion test reads. + bool RolesFitQuota(roleMap const& roles, RoleQuota const& quota, RoleQuota& leftover) + { + if (roles.size() > quota.Total()) + { + return false; + } - // check dungeon type for max of each role [normal heroic etc.] - LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(*itr); - if (dungeon) + std::vector masks; + masks.reserve(roles.size()); + + for (roleMap::const_iterator it = roles.begin(); it != roles.end(); ++it) + { + uint8 const offered = uint8(it->second & ~PLAYER_ROLE_LEADER); + if (!offered) + { + return false; // no role ticked at all -- cannot be placed + } + + masks.push_back(offered); + } + + // Least-flexible player first, so the search prunes early. + std::sort(masks.begin(), masks.end(), [](uint8 a, uint8 b) + { + uint8 popA = uint8((a & 2 ? 1 : 0) + (a & 4 ? 1 : 0) + (a & 8 ? 1 : 0)); + uint8 popB = uint8((b & 2 ? 1 : 0) + (b & 4 ? 1 : 0) + (b & 8 ? 1 : 0)); + return popA < popB; + }); + + leftover = quota; + + std::set deadEnds; + return AssignRolesRecursive(masks, 0, quota, leftover, deadEnds); + } + + /// The role composition a dungeon actually wants, straight off its DBC row. + /// + /// Not every queueable row is a 1/1/3 five-man: the shipped LfgDungeons.dbc carries + /// 0/0/3 scenarios, 0/0/1 solo content, 2/6/17 raid finder and 0/0/25 flexible raid. + /// Reading the row instead of assuming NORMAL_* is what lets those queue at all. + bool GetDungeonQuota(std::set const& dungeonList, RoleQuota& quota) { - // atm we're just handling DUNGEON_DIFFICULTY_NORMAL. - // - // Same raw-vs-internal confusion as GetDungeonType: LfgDungeons.dbc DifficultyID is a raw - // client id, so comparing it to DUNGEON_DIFFICULTY_NORMAL (internal 0) matched only rows - // carrying raw 0 -- 60 of them -- and never raw 1, which is what a 5-man normal dungeon - // actually is. 60 rather than the 59 given before: the old comparison had no TypeID - // filter, so its match set included id 358, 10v10 Rated Battleground, which is raid-typed. - // 59 is the non-raid subset, which is not what the code being described matched. The 90 normal-dungeon rows therefore left neededTanks, - // neededHealers and neededDps at their default, so the role counts were never initialised - // for the one case this branch claims to handle. - // ...and only for FIVE-MAN rows. NORMAL_TANK_OR_HEALER_COUNT and NORMAL_DAMAGE_COUNT are 1, - // 1 and 3 -- a 5-man composition. Raid rows carry raw DifficultyID 3 (10-normal) and 9 - // (legacy 40-player), both of which translate to internal 0, so without the TypeID test the - // translation would newly hand a 10, 25 or 40-player raid a one-tank/one-healer/three-dps - // requirement. Before the translation those rows compared raw 3 and 9 against internal 0 and - // missed, so they were excluded by accident. - // - // LfgDungeons.dbc does supply per-row Count_tank, Count_healer and Count_damage, which is - // where raid compositions should eventually come from. Reading them here would change the - // 5-man numbers too, so it is left out of this fix; the point of this branch is the key - // space, and it must not silently start sizing raid groups. - if (dungeon->TypeID != LFG_TYPE_RAID && - ToInternalDifficulty(dungeon->DifficultyID) == int32(DUNGEON_DIFFICULTY_NORMAL)) + if (dungeonList.empty()) { - information->neededTanks = NORMAL_TANK_OR_HEALER_COUNT - tankCount; - information->neededHealers = NORMAL_TANK_OR_HEALER_COUNT - healCount; - information->neededDps = NORMAL_DAMAGE_COUNT - dpsCount; + return false; } + + LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(*dungeonList.begin()); + if (!dungeon) + { + return false; + } + + quota.tank = uint8(dungeon->Count_tank); + quota.healer = uint8(dungeon->Count_healer); + quota.damage = uint8(dungeon->Count_damage); + + return quota.Total() != 0; } +} + +bool LFGMgr::RolesAreValidForDungeons(roleMap const& roles, std::set const& dungeonList) +{ + RoleQuota quota; + if (!GetDungeonQuota(dungeonList, quota)) + { + return false; + } + + RoleQuota leftover; + return RolesFitQuota(roles, quota, leftover); +} + +void LFGMgr::UpdateNeededRoles(ObjectGuid guid, LFGPlayers* information) +{ + // The role composition comes from the DUNGEON, not from a difficulty test. + // + // This previously read `if (dungeon->DifficultyID == DUNGEON_DIFFICULTY_NORMAL)`, + // comparing a RAW client DifficultyID against the internal 0-based enum -- two + // different key spaces at 5.4.8. The shipped LfgDungeons.dbc carries no queueable + // row with DifficultyID 0 at all (TypeID 1 has {1,2,7,11,12,14}, TypeID 6 has + // {1,2,11,12}), so the branch NEVER fired and the needed-role counts stayed at zero + // for every entry. With zeros, RoleMapsAreCompatible computed (3-0)+(3-0) = 6 > 3 + // and refused every pair, so the matchmaker formed nothing at all. + RoleQuota quota; + if (!GetDungeonQuota(information->dungeonList, quota)) + { + m_playerData[guid] = *information; + return; + } + + RoleQuota leftover; + if (!RolesFitQuota(information->currentRoles, quota, leftover)) + { + // No assignment places everyone present -- the entry is over-subscribed on some + // role. Report the dungeon's full requirement so it advertises as unsatisfiable + // rather than wrapping a uint8 and claiming 254 damage slots are free. + leftover = quota; + } + + // The resolver already clamped these: they count down from the quota as players are + // placed and can never go below zero, so the old `1 - tankCount` uint8 wrap that + // turned a two-tank party into "254 more tanks welcome" cannot recur. + information->neededTanks = leftover.tank; + information->neededHealers = leftover.healer; + information->neededDps = leftover.damage; m_playerData[guid] = *information; } @@ -548,6 +699,12 @@ void LFGMgr::AddToQueue(ObjectGuid guid) { m_queueSet.insert(guid); } + + // Tell the client its queue status straight away. Retail's first SMSG_LFG_QUEUE_STATUS + // lands 1-5s after the join, long before any tick would fire -- see SendQueueStatusFor. + // This must stay AFTER UpdateNeededRoles above, which fills the tank/healer/dps counts + // the packet carries; sending first would report a queue that needs nobody. + SendQueueStatusFor(guid, time(0)); } void LFGMgr::RemoveFromQueue(ObjectGuid guid) @@ -636,12 +793,326 @@ void LFGMgr::AddToWaitMap(uint8 role, std::set dungeons) } } +bool LFGMgr::HasLiveProposalFor(ObjectGuid plrGuid) const +{ + for (proposalMap::const_iterator it = m_proposalMap.begin(); it != m_proposalMap.end(); ++it) + { + if (it->second.answers.find(plrGuid) != it->second.answers.end()) + { + return true; + } + } + + return false; +} + +ObjectGuid LFGMgr::FindQueueEntryContaining(ObjectGuid plrGuid) const +{ + // Their own key first: that is the common case and it is O(1). + playerData::const_iterator own = m_playerData.find(plrGuid); + if (own != m_playerData.end()) + { + return plrGuid; + } + + // Otherwise they were merged into somebody else's entry, or queued as part of a + // party keyed by the group guid. + for (playerData::const_iterator it = m_playerData.begin(); it != m_playerData.end(); ++it) + { + if (it->second.currentRoles.find(plrGuid) != it->second.currentRoles.end()) + { + return it->first; + } + } + + return ObjectGuid(); +} + +void LFGMgr::RemovePlayerFromQueue(ObjectGuid plrGuid) +{ + ObjectGuid const entryGuid = FindQueueEntryContaining(plrGuid); + + m_playerStatusMap.erase(plrGuid); + + if (!entryGuid) + { + m_queueSet.erase(plrGuid); + m_playerData.erase(plrGuid); + return; + } + + LFGPlayers* entry = GetPlayerOrPartyData(entryGuid); + if (!entry) + { + return; + } + + entry->currentRoles.erase(plrGuid); + + // Last one out takes the entry with them. + if (entry->currentRoles.empty()) + { + m_queueSet.erase(entryGuid); + m_playerData.erase(entryGuid); + return; + } + + // The survivors need one fewer of whatever this player was covering. + UpdateNeededRoles(entryGuid, entry); +} + +bool LFGMgr::EntryHasGameMaster(LFGPlayers const* entry) const +{ + if (!entry) + { + return false; + } + + for (roleMap::const_iterator it = entry->currentRoles.begin(); it != entry->currentRoles.end(); ++it) + { + Player* pPlayer = sObjectAccessor.FindPlayer(it->first); + + // Account security, not `.gm on`. The operator should not have to make + // themselves untargetable just to test the dungeon finder. + if (pPlayer && pPlayer->GetSession() && + pPlayer->GetSession()->GetSecurity() >= SEC_GAMEMASTER) + { + return true; + } + } + + return false; +} + +bool LFGMgr::TryFormGroup(ObjectGuid guid) +{ + LFGPlayers* entry = GetPlayerOrPartyData(guid); + if (!entry || entry->currentState != LFG_STATE_QUEUED) + { + return false; + } + + // `.debug dungeon` lets an entry containing a game master go without a full + // composition, so the operator can drive the whole proposal -> group -> teleport + // chain without finding four other people. Everyone else still needs a real group. + bool const debugComplete = m_debugMode != LFG_DEBUG_OFF && EntryHasGameMaster(entry); + + if (!debugComplete && (entry->neededTanks || entry->neededHealers || entry->neededDps)) + { + return false; + } + + // Everyone in the entry must be online. SendDungeonProposal skips offline players + // when filling `groups` and `answers` while `currentRoles` still counts them toward + // the completed composition, so the online members could all accept, `allOkay` would + // see no pending answer for the absent one, and a SHORT group would be built and + // teleported in. Drop them from the entry instead and let it re-fill. + std::vector offline; + for (roleMap::const_iterator it = entry->currentRoles.begin(); it != entry->currentRoles.end(); ++it) + { + if (!sObjectAccessor.FindPlayer(it->first)) + { + offline.push_back(it->first); + } + } + + if (!offline.empty()) + { + for (std::vector::const_iterator it = offline.begin(); it != offline.end(); ++it) + { + entry->currentRoles.erase(*it); + m_playerStatusMap.erase(*it); + } + + if (entry->currentRoles.empty()) + { + m_queueSet.erase(guid); + m_playerData.erase(guid); + return false; + } + + UpdateNeededRoles(guid, entry); + return false; // no longer complete; stays queued and keeps looking + } + + // Out of the MATCH set, but the entry itself stays. Leaving it in m_queueSet would + // have it matched again next tick and fire a fresh proposal -- and a new + // SMSG_LFG_PROPOSAL_UPDATE -- every tick forever. Keeping m_playerData is what lets + // a declined or timed-out proposal put the survivors back in the queue rather than + // ejecting them from the dungeon finder. + m_queueSet.erase(guid); + entry->currentState = LFG_STATE_PROPOSAL; + + SendDungeonProposal(guid, entry); + return true; +} + +void LFGMgr::CancelProposal(uint32 proposalId, std::set const& culprits) +{ + proposalMap::iterator it = m_proposalMap.find(proposalId); + if (it == m_proposalMap.end()) + { + return; + } + + LFGProposal proposal = it->second; // copy: the map entry is erased below + m_proposalMap.erase(it); + + // Tell every client the proposal is over so the window closes. + proposal.state = LFG_PROPOSAL_FAILED; + for (proposalAnswerMap::const_iterator ans = proposal.answers.begin(); + ans != proposal.answers.end(); ++ans) + { + if (Player* pMember = sObjectAccessor.FindPlayer(ans->first)) + { + pMember->GetSession()->SendLfgProposalUpdate(proposal); + } + } + + LFGPlayers* entry = GetPlayerOrPartyData(proposal.queueGuid); + + // The players responsible leave the dungeon finder outright -- the client says so: + // "You have been removed from the queue because you did not accept the invitation." + // + // The entry keyed by proposal.queueGuid is deliberately NOT erased in this loop. + // A culprit is very often the entry key itself -- the solo player whose entry did + // the absorbing, or the single queuer in a `.debug dungeon` proposal -- and erasing + // m_playerData[queueGuid] here destroyed the node `entry` points into, which the + // survivor check below then read. An ordinary decline was a use-after-free on the + // world thread. + for (std::set::const_iterator bad = culprits.begin(); bad != culprits.end(); ++bad) + { + if (entry) + { + entry->currentRoles.erase(*bad); + } + + SetPlayerState(*bad, LFG_STATE_NONE); + SetPlayerUpdateType(*bad, LFG_UPDATE_LEAVE); + SendLfgUpdate(*bad, GetPlayerStatus(*bad), false); + + m_playerStatusMap.erase(*bad); + + if (*bad == proposal.queueGuid) + { + continue; // handled below, after `entry` is finished with + } + + m_queueSet.erase(*bad); + m_playerData.erase(*bad); + } + + if (!entry || entry->currentRoles.empty()) + { + m_queueSet.erase(proposal.queueGuid); + m_playerData.erase(proposal.queueGuid); + return; + } + + // Everyone else goes back in: "You have been returned to the front of the queue." + entry->currentState = LFG_STATE_QUEUED; + UpdateNeededRoles(proposal.queueGuid, entry); + + for (roleMap::const_iterator role = entry->currentRoles.begin(); + role != entry->currentRoles.end(); ++role) + { + SetPlayerState(role->first, LFG_STATE_QUEUED); + SetPlayerUpdateType(role->first, LFG_UPDATE_ADDED_TO_QUEUE); + SendLfgUpdate(role->first, GetPlayerStatus(role->first), false); + } + + m_queueSet.insert(proposal.queueGuid); +} + +void LFGMgr::CancelProposalsFor(ObjectGuid plrGuid) +{ + // Same teardown the expiry reaper performs, but driven by an explicit leave instead + // of the clock. The player is the culprit -- they are the one walking away -- so the + // others are requeued without them, exactly as a decline would do. + std::vector owned; + for (proposalMap::const_iterator it = m_proposalMap.begin(); it != m_proposalMap.end(); ++it) + { + if (it->second.answers.find(plrGuid) != it->second.answers.end()) + { + owned.push_back(it->first); + } + } + + // Collected first: CancelProposal erases from the map being walked. + for (std::vector::const_iterator it = owned.begin(); it != owned.end(); ++it) + { + std::set culprit; + culprit.insert(plrGuid); + CancelProposal(*it, culprit); + } +} + +void LFGMgr::RemoveOldProposals() +{ + time_t const now = time(NULL); + + std::vector expired; + for (proposalMap::const_iterator it = m_proposalMap.begin(); it != m_proposalMap.end(); ++it) + { + if (it->second.createdTime && (now - it->second.createdTime) >= LFG_TIME_PROPOSAL) + { + expired.push_back(it->first); + } + } + + // Collected first: CancelProposal erases from the map being walked. + // + // Without this reaper a recipient who ignored the popup, disconnected, or whose + // client-side timer lapsed left everyone else pinned at LFG_STATE_PROPOSAL for ever + // -- and JoinLFG refuses that state, so they could not re-queue until relog. + for (std::vector::const_iterator it = expired.begin(); it != expired.end(); ++it) + { + proposalMap::const_iterator prop = m_proposalMap.find(*it); + if (prop == m_proposalMap.end()) + { + continue; + } + + // Whoever did not answer is the culprit, exactly as a decliner would be. + // + // Cancelling with an empty culprit set requeued the entry UNCHANGED, including + // the member who never responded. The role counts were still complete, so the + // very next tick re-formed the same proposal and timed out again -- trapping the + // players who did accept in a permanent timeout loop. + std::set silent; + for (proposalAnswerMap::const_iterator ans = prop->second.answers.begin(); + ans != prop->second.answers.end(); ++ans) + { + if (ans->second != LFG_ANSWER_AGREE || !sObjectAccessor.FindPlayer(ans->first)) + { + silent.insert(ans->first); + } + } + + CancelProposal(*it, silent); + } +} + void LFGMgr::FindQueueMatches() { - // Fetch information on all the queued players/groups - for (queueSet::iterator itr = m_queueSet.begin(); itr != m_queueSet.end(); ++itr) + // Snapshot: MergeGroups and TryFormGroup both erase from m_queueSet, and erasing the + // element an active iterator points at is UB. + queueSet const snapshot = m_queueSet; + + for (queueSet::const_iterator itr = snapshot.begin(); itr != snapshot.end(); ++itr) { + // An entry can be absorbed or dequeued by an earlier iteration of this same pass. + if (m_queueSet.find(*itr) == m_queueSet.end()) + { + continue; + } + FindSpecificQueueMatches(*itr); + + // A party that arrives already complete -- the common premade-of-five case -- + // is never merged with anything, so the completion test inside MergeGroups + // never sees it. Without this check such a group waits in the queue forever. + TryFormGroup(*itr); } } @@ -654,13 +1125,28 @@ void LFGMgr::FindSpecificQueueMatches(ObjectGuid guid) // compare to everyone else in queue for compatibility // after a match is found call UpdateNeededRoles // Use the roleMap to store player guid/role information; merge into queueInfo struct & delete other struct/map entry - for (queueSet::iterator itr = m_queueSet.begin(); itr != m_queueSet.end(); ++itr) + queueSet const snapshot = m_queueSet; + + for (queueSet::const_iterator itr = snapshot.begin(); itr != snapshot.end(); ++itr) { if (*itr == guid) { continue; } + // Absorbed by an earlier merge in this same pass, or dequeued by a proposal. + if (m_queueSet.find(*itr) == m_queueSet.end()) + { + continue; + } + + // Re-read: MergeGroups mutates the entry we are accumulating into. + queueInfo = GetPlayerOrPartyData(guid); + if (!queueInfo) + { + return; + } + LFGPlayers* matchInfo = GetPlayerOrPartyData(*itr); if (matchInfo) { @@ -684,7 +1170,7 @@ void LFGMgr::FindSpecificQueueMatches(ObjectGuid guid) { // check for player / role count and also team compatibility // if function returns true, then merge groups into one - if (RoleMapsAreCompatible(queueInfo, matchInfo) && MatchesAreOfSameTeam(queueInfo, matchInfo)) + if (RoleMapsAreCompatible(queueInfo, matchInfo, compatibleDungeons) && MatchesAreOfSameTeam(queueInfo, matchInfo)) { MergeGroups(guid, *itr, compatibleDungeons); } @@ -694,35 +1180,59 @@ void LFGMgr::FindSpecificQueueMatches(ObjectGuid guid) } } -bool LFGMgr::RoleMapsAreCompatible(LFGPlayers* groupOne, LFGPlayers* groupTwo) +bool LFGMgr::RoleMapsAreCompatible(LFGPlayers* groupOne, LFGPlayers* groupTwo, + std::set const& compatibleDungeons) { - // When this is called we already know that the dungeons match, so just focus on roles - // compare: neededX(role) from each struct and the amount of people per role in the roleMap - if ((groupOne->currentRoles.size() + groupTwo->currentRoles.size()) > NORMAL_TOTAL_ROLE_COUNT) + // When this is called we already know the dungeons overlap, so just focus on roles. + // + // The question is simply: if these two entries were one, could every player in the + // union fill a distinct slot the dungeon actually has? Asking the resolver directly + // replaces the old per-role arithmetic, which recovered "present" as + // (NORMAL_X - neededX) and so inherited every uint8 wrap in neededX -- a two-tank + // party gave (1-255) + (1-0) = -254, which passed the cap test and merged a party + // that could never complete. + // + // It also drops the hardcoded 1/1/3/5, which is wrong for the 108 of 247 queueable + // TypeID 1 rows that are scenarios (0/0/3), solo content (0/0/1), raid finder + // (2/6/17) or flexible raid (0/0/25). + RoleQuota quota; + if (!GetDungeonQuota(compatibleDungeons, quota)) { return false; } - else + + if ((groupOne->currentRoles.size() + groupTwo->currentRoles.size()) > quota.Total()) { - // make sure we don't have too many players of a certain role here - if (((NORMAL_DAMAGE_COUNT - groupOne->neededDps) + (NORMAL_DAMAGE_COUNT - groupTwo->neededDps)) > NORMAL_DAMAGE_COUNT) - { - return false; - } - else if (((NORMAL_TANK_OR_HEALER_COUNT - groupOne->neededHealers) + (NORMAL_TANK_OR_HEALER_COUNT - groupTwo->neededHealers)) > NORMAL_TANK_OR_HEALER_COUNT) - { - return false; - } - else if (((NORMAL_TANK_OR_HEALER_COUNT - groupOne->neededTanks) + (NORMAL_TANK_OR_HEALER_COUNT - groupTwo->neededTanks)) > NORMAL_TANK_OR_HEALER_COUNT) - { - return false; - } - else - { - return true; // the player/role counts line up! - } + return false; } - return false; + + // `.debug dungeon group`: an entry containing a game master takes whoever else is + // waiting, whatever they picked. The size cap above still applies, and the duplicate + // check below still applies -- only the role composition is waived, and only when a + // GM is involved. + bool const debugMerge = m_debugMode == LFG_DEBUG_GROUP && + (EntryHasGameMaster(groupOne) || EntryHasGameMaster(groupTwo)); + + roleMap combined = groupOne->currentRoles; + for (roleMap::const_iterator it = groupTwo->currentRoles.begin(); it != groupTwo->currentRoles.end(); ++it) + { + combined[it->first] = it->second; + } + + // A player present in both entries collapses to one key, so the union can be smaller + // than the sum -- that is the duplicate-membership case and it must not merge. + if (combined.size() != groupOne->currentRoles.size() + groupTwo->currentRoles.size()) + { + return false; + } + + if (debugMerge) + { + return true; + } + + RoleQuota leftover; + return RolesFitQuota(combined, quota, leftover); } bool LFGMgr::MatchesAreOfSameTeam(LFGPlayers* groupOne, LFGPlayers* groupTwo) @@ -778,13 +1288,19 @@ void LFGMgr::MergeGroups(ObjectGuid guidOne, ObjectGuid guidTwo, std::setneededTanks == 0) && (mainGroup->neededHealers == 0) && (mainGroup->neededDps == 0)) - { - SendDungeonProposal(mainGroup); - } - + // Both containers, or guidTwo lingers in m_queueSet pointing at data that no + // longer exists. That stale entry is not merely a leak: the merged-away + // player still reads LFG_STATE_QUEUED, SendQueueStatus keys off m_playerData + // so their client never hears again, and a re-queue skips JoinLFG's + // duplicate cleanup (it is guarded on existing data) -- leaving that player + // live in a fresh solo entry AND still listed in the merged entry's roles, + // which can produce two proposals for the same person. + m_queueSet.erase(guidTwo); m_playerData.erase(guidTwo); + + // Completion is decided after the absorbed entry is gone, so the proposal is built + // from one consistent view and TryFormGroup can dequeue the survivor safely. + TryFormGroup(guidOne); } void LFGMgr::SendQueueStatus() @@ -795,61 +1311,139 @@ void LFGMgr::SendQueueStatus() // Check who is listed as being in the queue for (queueSet::iterator itr = m_queueSet.begin(); itr != m_queueSet.end(); ++itr) { - // make sure it's not a false entry - LFGPlayers* queueInfo = GetPlayerOrPartyData(*itr); - if (queueInfo && queueInfo->currentState == LFG_STATE_QUEUED) + SendQueueStatusFor(*itr, timeNow); + } +} + +// Split out of SendQueueStatus so a single queue can be told its status the moment it is +// created, rather than only on the next matchmaker tick. +// +// Retail sends the first SMSG_LFG_QUEUE_STATUS within seconds of the join: across 55 queued +// sessions at build 18414 the delay from CMSG_LFG_JOIN to the first status is 1-5s (median 4), +// and it then repeats roughly every 35s. capture-000044 seq 1577 confirms both halves of that +// -- its queuedTime field reads 3, matching the 3s the index measured since the join. +// +// Tick-only delivery could not reproduce that. SendQueueStatus runs at the END of Update(), +// AFTER FindQueueMatches, so a queue that matched on its first tick was dequeued before the +// status was ever built and the player got NONE at all -- no role counts, no average wait, an +// empty eye tooltip. Observed live on a solo debug queue that matched 29s after joining. +void LFGMgr::SendQueueStatusFor(ObjectGuid queueGuid, time_t timeNow) +{ + // make sure it's not a false entry + LFGPlayers* queueInfo = GetPlayerOrPartyData(queueGuid); + if (!queueInfo || queueInfo->currentState != LFG_STATE_QUEUED) + { + return; + } + + // Guarded because this now also runs at join time: dungeonList.begin() on an empty + // set is undefined, and an empty list is reachable if every candidate was filtered. + if (queueInfo->dungeonList.empty()) + { + return; + } + + for (roleMap::iterator rItr = queueInfo->currentRoles.begin(); rItr != queueInfo->currentRoles.end(); ++rItr) + { + if (Player* pPlayer = sObjectAccessor.FindPlayer(rItr->first)) { - for (roleMap::iterator rItr = queueInfo->currentRoles.begin(); rItr != queueInfo->currentRoles.end(); ++rItr) + uint32 dungeonId = *queueInfo->dungeonList.begin(); + + // Each recipient must be told about THEIR OWN queue, not the + // merged entry's key. + // + // The key is whichever entry did the absorbing, so after two solo + // players merge it is one of their guids. The other player joined + // under their own guid -- that is what SMSG_LFG_UPDATE_STATUS sent + // them as requesterGuid -- and a queue status arriving under a + // stranger's identity does not match the queue their client is + // tracking, so it is ignored: no role counts, no average wait, a + // placeholder time in queue, and most of the minimap eye's tooltip + // missing. The absorbing player saw none of this, because for them + // the merged key IS their own guid. + // + // Mirrors SendLfgUpdate: a party member's queue is keyed by the + // group guid, everyone else by their own. + ObjectGuid memberQueueGuid = rItr->first; + if (Group* pGroup = pPlayer->GetGroup()) { - if (Player* pPlayer = sObjectAccessor.FindPlayer(rItr->first)) + if (pGroup->GetObjectGuid() == queueGuid) { - uint32 dungeonId = *queueInfo->dungeonList.begin(); + memberQueueGuid = queueGuid; + } + } - LFGQueueStatus status; - status.queueGuid = itr->GetRawValue(); - status.dungeonID = dungeonId; - status.neededTanks = queueInfo->neededTanks; - status.neededHeals = queueInfo->neededHealers; - status.neededDps = queueInfo->neededDps; - status.timeSpentInQueue = uint32(timeNow - queueInfo->joinedTime); - status.joinTime = uint32(queueInfo->joinedTime); + LFGQueueStatus status; + status.queueGuid = memberQueueGuid.GetRawValue(); + status.dungeonID = dungeonId; + status.neededTanks = queueInfo->neededTanks; + status.neededHeals = queueInfo->neededHealers; + status.neededDps = queueInfo->neededDps; + status.timeSpentInQueue = uint32(timeNow - queueInfo->joinedTime); + status.joinTime = uint32(queueInfo->joinedTime); + status.ticketId = queueInfo->ticketId; - int32 playerWaitTime; + int32 playerWaitTime; - // strip leader flag from role - uint8 withoutLeader = rItr->second; - withoutLeader &= ~PLAYER_ROLE_LEADER; + // strip leader flag from role + uint8 withoutLeader = rItr->second; + withoutLeader &= ~PLAYER_ROLE_LEADER; - switch (withoutLeader) - { - case PLAYER_ROLE_TANK: - playerWaitTime = m_tankWaitTime[dungeonId].time; - break; - case PLAYER_ROLE_HEALER: - playerWaitTime = m_healerWaitTime[dungeonId].time; - break; - case PLAYER_ROLE_DAMAGE: - playerWaitTime = m_dpsWaitTime[dungeonId].time; - break; - default: - playerWaitTime = m_avgWaitTime[dungeonId].time; - break; - } + switch (withoutLeader) + { + case PLAYER_ROLE_TANK: + playerWaitTime = m_tankWaitTime[dungeonId].time; + break; + case PLAYER_ROLE_HEALER: + playerWaitTime = m_healerWaitTime[dungeonId].time; + break; + case PLAYER_ROLE_DAMAGE: + playerWaitTime = m_dpsWaitTime[dungeonId].time; + break; + default: + playerWaitTime = m_avgWaitTime[dungeonId].time; + break; + } - status.playerAvgWaitTime = playerWaitTime; - status.dpsAvgWaitTime = m_dpsWaitTime[dungeonId].time; - status.healerAvgWaitTime = m_healerWaitTime[dungeonId].time; - status.tankAvgWaitTime = m_tankWaitTime[dungeonId].time; - status.avgWaitTime = m_avgWaitTime[dungeonId].time; + status.playerAvgWaitTime = playerWaitTime; + status.dpsAvgWaitTime = m_dpsWaitTime[dungeonId].time; + status.healerAvgWaitTime = m_healerWaitTime[dungeonId].time; + status.tankAvgWaitTime = m_tankWaitTime[dungeonId].time; + status.avgWaitTime = m_avgWaitTime[dungeonId].time; - // Send packet to client - pPlayer->GetSession()->SendLfgQueueStatus(status); - } - } + // Send packet to client + pPlayer->GetSession()->SendLfgQueueStatus(status); } } } +uint32 LFGMgr::GetGroupDungeonEntry(ObjectGuid groupGuid) +{ + // The Group object itself does not know which dungeon it is for -- isLFGGroup() is + // only a bit in m_groupType -- so the resolved id has to come from the group status + // LFGMgr records when the dungeon group is created. + LFGGroupStatus const* status = GetGroupStatus(groupGuid); + return status ? GetDungeonEntry(status->dungeonID) : 0; +} + +void LFGMgr::ReleaseGroupLfgStatus(ObjectGuid groupGuid) +{ + m_groupStatusMap.erase(groupGuid); + m_groupSet.erase(groupGuid); +} + +uint32 LFGMgr::GetGroupRandomDungeonEntry(ObjectGuid groupGuid) +{ + LFGGroupStatus const* status = GetGroupStatus(groupGuid); + return (status && status->randomDungeonID) ? GetDungeonEntry(status->randomDungeonID) : 0; +} + +LFGState LFGMgr::GetGroupLfgState(ObjectGuid groupGuid) +{ + LFGGroupStatus const* status = GetGroupStatus(groupGuid); + return status ? status->state : LFG_STATE_NONE; +} + uint32 LFGMgr::GetDungeonEntry(uint32 ID) { LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(ID); diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index f0fab1def..fdbcde51c 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -103,14 +103,102 @@ namespace MopLfgPackets uint32 dungeonEntry = 0; }; + /// One entry of a role check, in the order the client expects them: leader first. + struct RoleCheckMember + { + uint64 guid = 0; + uint32 roles = 0; + uint8 level = 0; + }; + + struct RoleCheckUpdate + { + std::vector members; + std::vector dungeonEntries; + uint8 partyIndex = 0; + uint8 state = 0; + }; + + /// Wire value of LFG_ROLECHECK_INITIALITING. + /// + /// Spelled out here because LFGRoleCheckState is declared further down this header, + /// after these inline builders. A static_assert next to the enum keeps the two from + /// drifting apart. + uint8 const ROLE_CHECK_STATE_INITIATING = 2; + + /// One participant of a dungeon proposal. + struct ProposalPlayer + { + uint32 roles = 0; + bool inProposedGroup = false; // already in the group the proposal will reuse + bool isSelf = false; // is this the recipient + bool answered = false; + bool agreed = false; + bool sameGroupAsSelf = false; + }; + + struct ProposalUpdate + { + std::vector players; + uint64 requesterGuid = 0; // recipient's original group, else the player + uint64 instanceGuid = 0; // see BuildProposalUpdate + uint32 dungeonEntry = 0; + uint32 clientQueueId = 0; + uint32 proposalId = 0; + uint32 joinTime = 0; + uint32 encounters = 0; + uint32 flags = 3; + uint8 state = 0; + bool silent = false; // update an open window instead of opening one + }; + bool BuildBootPlayer(WorldPacket& out, BootUpdate const& update); + void BuildRoleCheckUpdate(WorldPacket& out, RoleCheckUpdate const& update); + void BuildProposalUpdate(WorldPacket& out, ProposalUpdate const& update); bool BuildUpdateStatus(WorldPacket& out, StatusUpdate const& update); void BuildQueueStatus(WorldPacket& out, QueueStatusUpdate const& update); bool ParseLfrSearchRequest(WorldPacket& in, LfrSearchRequest& request); void BuildEmptyLfrSearchResponse(WorldPacket& out, LfrSearchRequest const& request); bool ParseLockInfoRequest(WorldPacket& in, bool& forPlayer); + /// One entry of the lock array at the tail of SMSG_LFG_PLAYER_INFO. + struct PlayerLockInfo + { + /// (TypeID << 24) | dungeonId -- the same value LfgDungeonsEntry::Entry() produces. + uint32 dungeonEntry = 0; + /// LFGForbiddenTypes, which are the client's LFG_INSTANCE_INVALID_CODES verbatim. + uint32 lockStatus = 0; + /// Only meaningful for the gear-score reasons, where the client formats them as + /// "Requires: %2$d. Currently %3$d." Zero for every other reason, and zero in all + /// 206 records of the reference capture. + uint32 subReason1 = 0; + uint32 subReason2 = 0; + }; + void BuildEmptyPlayerInfo(WorldPacket& out); + void BuildPlayerInfo(WorldPacket& out, std::vector const& locks); void BuildEmptyPartyInfo(WorldPacket& out); + + /// One party member's lock list inside SMSG_LFG_JOIN_RESULT. Reuses PlayerLockInfo + /// because the 16-byte lock record is the same one SMSG_LFG_PLAYER_INFO carries -- + /// only the field ORDER on the wire differs between the two packets. + struct JoinResultPlayer + { + uint64 guid = 0; + std::vector locks; + }; + + struct JoinResult + { + std::vector players; // empty for every refusal observed + uint64 requesterGuid = 0; // zero on a refusal + uint32 joinTime = 0; // queue ticket, shared with SMSG_LFG_QUEUE_STATUS + uint32 clientQueueId = 0; + uint32 ticketType = 0; // 3 on success, 0 on every observed refusal + uint8 result = 0; // LfgJoinResult + uint8 detail = 0; // LFGRoleCheckState; only read when result == 0x1C + }; + + void BuildJoinResult(WorldPacket& out, JoinResult const& update); } namespace MopLfgPacketDetail @@ -135,6 +223,163 @@ namespace MopLfgPacketDetail } } +inline void MopLfgPackets::BuildRoleCheckUpdate(WorldPacket& out, + RoleCheckUpdate const& update) +{ + // SMSG_LFG_ROLE_CHECK_UPDATE (0x12BB). + // + // The body that stood here was the 3.3.5 shape -- a uint32 state, flat counts and + // raw uint64 GUIDs -- and shared no field order with 18414. Verified byte-exact + // against two real captures of different shape, decoding to zero leftover bytes: + // + // capture-000075 seq 891708, 35 B: partyIndex 0, state 2, 2 members, 1 dungeon + // capture-000059 seq 719547, 68 B: partyIndex 1, state 2, 5 members, 1 dungeon + // + // Corpus catalogueGenerationId 2BE10C89...88752. + // + // Note partyIndex is NOT always zero -- the second capture carries 1 -- so it is a + // real field rather than padding, even though GetLFGRoleUpdate does not surface it + // to Lua (the client stores it at dword_1209678 and reads it elsewhere). + // + // The "random dungeon" GUID is always empty in observed traffic; its mask bits are + // written all-zero and WriteByteSeq emits nothing for a zero byte, so it costs 8 + // mask bits and no bytes. It is kept explicit because the bit positions are + // interleaved with the dungeon count and cannot be collapsed away. + uint64 const randomDungeonGuid = 0; + + out << uint8(update.partyIndex); + out << uint8(update.state); + + out.WriteBits(uint32(update.members.size()), 21); + + for (std::vector::const_iterator it = update.members.begin(); + it != update.members.end(); ++it) + { + out.WriteBit(it->roles > 0); // has this member answered yet + MopLfgPacketDetail::WriteGuidMask(out, it->guid, { 3, 0, 5, 2, 7, 1, 4, 6 }); + } + + MopLfgPacketDetail::WriteGuidMask(out, randomDungeonGuid, { 3, 5 }); + out.WriteBits(uint32(update.dungeonEntries.size()), 22); + MopLfgPacketDetail::WriteGuidMask(out, randomDungeonGuid, { 0, 7, 6, 1, 4, 2 }); + out.WriteBit(update.state == ROLE_CHECK_STATE_INITIATING); + + out.FlushBits(); + + MopLfgPacketDetail::WriteGuidBytes(out, randomDungeonGuid, { 0 }); + + for (std::vector::const_iterator it = update.members.begin(); + it != update.members.end(); ++it) + { + out << uint8(it->level); + MopLfgPacketDetail::WriteGuidBytes(out, it->guid, { 3, 6 }); + out << uint32(it->roles); + MopLfgPacketDetail::WriteGuidBytes(out, it->guid, { 2, 4, 0, 1, 5, 7 }); + } + + MopLfgPacketDetail::WriteGuidBytes(out, randomDungeonGuid, { 1, 7, 6, 4, 3, 2, 5 }); + + for (std::vector::const_iterator it = update.dungeonEntries.begin(); + it != update.dungeonEntries.end(); ++it) + { + out << uint32(*it); + } +} + +inline void MopLfgPackets::BuildProposalUpdate(WorldPacket& out, + ProposalUpdate const& update) +{ + // SMSG_LFG_PROPOSAL_UPDATE (0x1E3B). + // + // The body that stood here was the 3.3.5 shape -- flat uint32/uint8 fields and a + // per-player run of single bytes -- and shared no field order with 18414. Verified + // byte-exact against two real captures chosen to differ as much as possible: + // + // capture-000044 seq 1948, 64 B: 5 players, 1 tank / 1 healer / 3 dps + // capture-000059 seq 2063424, 156 B: 25 players, 2 tank / 6 healer / 17 dps + // + // Both decode to zero leftover. The second is a raid finder proposal, and its + // composition matches the 2/6/17 that LfgDungeons.dbc carries for LFR rows -- an + // independent check on the decode from a completely different evidence source. + // + // Corpus catalogueGenerationId 2BE10C89...88752. + // + // Two corrections to the reference layout this was checked against: + // + // - It builds the second GUID as `dungeonEntry | (0x1F45 << 48)`. Real traffic + // carries neither: the top five bytes are constant 1F 44 00 00 11 in both + // captures while the low three vary, i.e. a genuine instance-side GUID with a + // counter, unrelated to the dungeon entry. We do not model that object, so we + // send zero -- a legal encoding, since all eight mask bits then read false and + // WriteByteSeq emits nothing. If a live client turns out to need it to match the + // proposal, synthesise it from proposalId rather than guessing a constant. + // + // - Roles are passed through verbatim. Observed values include 0x32 and 0x09, so + // bits above DAMAGE are real and must not be masked off. + out.WriteBit(MopLfgPacketDetail::GuidByte(update.instanceGuid, 6) != 0); + out.WriteBit(MopLfgPacketDetail::GuidByte(update.instanceGuid, 0) != 0); + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 1, 7, 5 }); + out.WriteBit(MopLfgPacketDetail::GuidByte(update.instanceGuid, 5) != 0); + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 4 }); + out.WriteBit(update.silent); + out.WriteBit(MopLfgPacketDetail::GuidByte(update.instanceGuid, 2) != 0); + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 6 }); + MopLfgPacketDetail::WriteGuidMask(out, update.instanceGuid, { 3, 7 }); + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 3 }); + + out.WriteBits(uint32(update.players.size()), 21); + + for (std::vector::const_iterator it = update.players.begin(); + it != update.players.end(); ++it) + { + out.WriteBit(it->inProposedGroup); + out.WriteBit(it->isSelf); + out.WriteBit(it->answered); + out.WriteBit(it->agreed); + out.WriteBit(it->sameGroupAsSelf); + } + + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 2 }); + MopLfgPacketDetail::WriteGuidMask(out, update.instanceGuid, { 4 }); + out.WriteBit(false); // unknown; zero in all observed traffic + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 0 }); + MopLfgPacketDetail::WriteGuidMask(out, update.instanceGuid, { 1 }); + + out.FlushBits(); + + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 1 }); + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 4 }); + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 4 }); + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 7, 2, 0 }); + + out << uint32(update.dungeonEntry); + out << uint8(update.state); + out << uint32(update.clientQueueId); + + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 6 }); + out << uint32(update.proposalId); + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 5, 3 }); + out << uint32(update.joinTime); + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 5 }); + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 6 }); + + for (std::vector::const_iterator it = update.players.begin(); + it != update.players.end(); ++it) + { + out << uint32(it->roles); + } + + out << uint32(update.encounters); + + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 7 }); + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 1 }); + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 0, 2 }); + + out << uint32(update.flags); + + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 3 }); +} + inline bool MopLfgPackets::ParseLfrSearchRequest(WorldPacket& in, LfrSearchRequest& request) { @@ -293,6 +538,65 @@ inline void MopLfgPackets::BuildQueueStatus(WorldPacket& out, MopLfgPacketDetail::WriteGuidBytes(out, update.queueGuid, { 5, 3, 6 }); } +inline void MopLfgPackets::BuildJoinResult(WorldPacket& out, + JoinResult const& update) +{ + // SMSG_LFG_JOIN_RESULT (0x18E3). + // + // Direct inverse of the 18414 reader sub_760C65, reached from dispatcher case 687. + // The body that stood here was the 3.3.5 shape -- uint32 result, uint32 state, then + // raw uint64 GUIDs -- which shares no field WIDTH with this client, let alone field + // order. That, plus the opcode never having been admitted, is why a refused join was + // silent in both directions. + // + // Verified byte-exact against all three observed sizes, decoding to zero leftover + // bytes and zero non-zero pad bits (catalogueGenerationId 2BE10C89...88752): + // + // capture-000059 seq 490545, 18 B: refusal, result 0x1C detail 6, guid 0, ticket 0 + // capture-000044 seq 1547, 23 B: success, guid 0x0400000006296291, type 3 + // capture-000075 seq 891753, 24 B: success, guid 0x1F5400001249B4F0, type 3 + // + // The governing identity when no locks are present is + // len == 18 + popcount(byte0) + popcount(byte3) + // because the GUID mask is SPLIT either side of the 22-bit lock count. + // + // capture-000044 cross-checks against SMSG_LFG_QUEUE_STATUS seq 1577 in the same + // capture: joinTime 0x54146107 and queueId 0x9BFF are identical in both, so the + // ticket really is one shared identifier rather than a per-packet value. + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 7, 6, 3, 0 }); + out.WriteBits(update.players.size(), 22); + for (JoinResultPlayer const& player : update.players) + { + MopLfgPacketDetail::WriteGuidMask(out, player.guid, { 3 }); + out.WriteBits(player.locks.size(), 20); + MopLfgPacketDetail::WriteGuidMask(out, player.guid, { 6, 1, 4, 7, 2, 0, 5 }); + } + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 5, 1, 4, 2 }); + out.FlushBits(); + + out << update.result; + for (JoinResultPlayer const& player : update.players) + { + MopLfgPacketDetail::WriteGuidBytes(out, player.guid, { 4 }); + for (PlayerLockInfo const& lock : player.locks) + { + // Reverse of the SMSG_LFG_PLAYER_INFO order: the dungeon entry is written + // LAST here, after both sub-reasons and the lock status. + out << lock.subReason2; + out << lock.subReason1; + out << lock.lockStatus; + out << lock.dungeonEntry; + } + MopLfgPacketDetail::WriteGuidBytes(out, player.guid, { 1, 0, 5, 7, 3, 6, 2 }); + } + out << update.detail; + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 2 }); + out << update.joinTime; + out << update.clientQueueId; + out << update.ticketType; + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 6, 4, 1, 0, 5, 7, 3 }); +} + inline bool MopLfgPackets::ParseLockInfoRequest(WorldPacket& in, bool& forPlayer) { @@ -309,6 +613,52 @@ inline bool MopLfgPackets::ParseLockInfoRequest(WorldPacket& in, return in.rpos() == in.size(); } +inline void MopLfgPackets::BuildPlayerInfo(WorldPacket& out, + std::vector const& locks) +{ + // SMSG_LFG_PLAYER_INFO with a populated lock list. + // + // Sent ONLY in reply to CMSG_LFG_LOCK_INFO_REQUEST -- it is not pushed at login. In + // capture-000006 the two pair seven-for-seven, and the client asks at world-enter + // (CMSG_LFG_GET_STATUS then CMSG_LFG_LOCK_INFO_REQUEST at adjacent sequence numbers). + // + // Layout verified byte-exact against capture-000006 seq 1953, a 6068-byte reply to a + // max-level character: + // + // bits WriteBits(lockCount, 20) -> 206 + // WriteBit(hasPlayerGuid) -> 0 + // WriteBits(randomDungeonCount, 17) -> 35 + // FlushBits -> 38 bits, 5 bytes + // ...random dungeon reward records, variable length... + // tail lockCount x 16 bytes, flat and unpacked: + // uint32 dungeonEntry (TypeID << 24) | id + // uint32 lockStatus + // uint32 subReason1 + // uint32 subReason2 + // + // The locks sit at the TAIL, after the random records. With zero randoms the two are + // adjacent, which is what makes a locks-only reply coherent: the client installs the + // lock list and raises LFG_LOCK_INFO_RECEIVED whether or not any random rows follow, + // so none of the reward plumbing is needed to make the eligibility filter work. + // + // Why this matters: LFGList_DefaultFilterFunction shows a dungeon when + // `not LFGLockList[dungeonID]`, and LFGLockList is built from this array. Sending it + // empty told the client nothing is locked, so every dungeon in the game appeared in + // the finder and players could queue for content they cannot enter. + out.WriteBits(uint32(locks.size()), 20); + out.WriteBit(false); // has player GUID -- 0 in the reference capture + out.WriteBits(0, 17); // random dungeon count; see above + out.FlushBits(); + + for (std::vector::const_iterator it = locks.begin(); it != locks.end(); ++it) + { + out << uint32(it->dungeonEntry); + out << uint32(it->lockStatus); + out << uint32(it->subReason1); + out << uint32(it->subReason2); + } +} + inline void MopLfgPackets::BuildEmptyPlayerInfo(WorldPacket& out) { out.WriteBits(0, 20); // locked dungeon count @@ -343,26 +693,56 @@ enum LFGFlags }; /// Possible statuses to send after a request to join the dungeon finder +/// Result codes for SMSG_LFG_JOIN_RESULT, build 18414. +/// +/// Re-valued from the 3.3.5 numbering this was inherited with. The client picks the +/// displayed string by LINEAR SCAN of a 19-entry {u32 code, u32 stringId} table at +/// .data:00F66A30, bounded by `cmp ecx, 13h` at .text:0098E80C. A code that is not in +/// that table takes the `jmp short loc_98E820` at .text:0098E811, which skips the +/// DisplayError call outright -- the player is shown NOTHING. Every value below was +/// resolved through the descriptor array at .data:00F5C278 (stride 0x14, name pointer +/// at +0x00), so these are table reads, not an ordering guess. +/// +/// The shift is NOT a constant: +0x1B for the old 0x01..0x05, then +0x1A from +/// MISMATCHED_SLOTS on, because MoP dropped NO_SLOTS_PARTY. A blanket offset would +/// silently mis-value two thirds of the enum. enum LfgJoinResult { - ERR_LFG_OK = 0x00, - ERR_LFG_ROLE_CHECK_FAILED = 0x01, - ERR_LFG_GROUP_FULL = 0x02, - ERR_LFG_NO_LFG_OBJECT = 0x04, - ERR_LFG_NO_SLOTS_PLAYER = 0x05, - ERR_LFG_NO_SLOTS_PARTY = 0x06, - ERR_LFG_MISMATCHED_SLOTS = 0x07, - ERR_LFG_PARTY_PLAYERS_FROM_DIFFERENT_REALMS = 0x08, - ERR_LFG_MEMBERS_NOT_PRESENT = 0x09, - ERR_LFG_GET_INFO_TIMEOUT = 0x0A, - ERR_LFG_INVALID_SLOT = 0x0B, - ERR_LFG_DESERTER_PLAYER = 0x0C, - ERR_LFG_DESERTER_PARTY = 0x0D, - ERR_LFG_RANDOM_COOLDOWN_PLAYER = 0x0E, - ERR_LFG_RANDOM_COOLDOWN_PARTY = 0x0F, - ERR_LFG_TOO_MANY_MEMBERS = 0x10, - ERR_LFG_CANT_USE_DUNGEONS = 0x11, - ERR_LFG_ROLE_CHECK_FAILED2 = 0x12, + ERR_LFG_OK = 0x00, // success; not in the table, client shows nothing + ERR_LFG_ROLE_CHECK_FAILED = 0x1C, // detail byte refines this one -- see LfgJoinResultDetail + ERR_LFG_GROUP_FULL = 0x1D, + ERR_LFG_NO_LFG_OBJECT = 0x1F, + ERR_LFG_NO_SLOTS_PLAYER = 0x20, // the only code that also carries the per-player lock array + ERR_LFG_MISMATCHED_SLOTS = 0x21, + ERR_LFG_PARTY_PLAYERS_FROM_DIFFERENT_REALMS = 0x22, + ERR_LFG_MEMBERS_NOT_PRESENT = 0x23, + ERR_LFG_GET_INFO_TIMEOUT = 0x24, + ERR_LFG_INVALID_SLOT = 0x25, + ERR_LFG_DESERTER_PLAYER = 0x26, + ERR_LFG_DESERTER_PARTY = 0x27, + ERR_LFG_RANDOM_COOLDOWN_PLAYER = 0x28, + ERR_LFG_RANDOM_COOLDOWN_PARTY = 0x29, + ERR_LFG_TOO_MANY_MEMBERS = 0x2A, + ERR_LFG_CANT_USE_DUNGEONS = 0x2B, + ERR_LFG_ROLE_CHECK_FAILED2 = 0x2C, // genuine second code; renders the same string as 0x1C + ERR_LFG_TOO_FEW_MEMBERS = 0x32, // MoP-new + ERR_LFG_REASON_TOO_MANY_LFG = 0x33, // MoP-new + ERR_LFG_MISMATCHED_SLOTS_LOCAL_XREALM = 0x35, // MoP-new + + // ERR_LFG_NO_SLOTS_PARTY is deliberately absent. Its string still exists in the + // client (index 0x2EE) but NO result code maps to it, so there is no way to send + // it. Callers must use ERR_LFG_NO_SLOTS_PLAYER for a party too -- it is the code + // that carries the lock array, so the player is told which dungeons were locked + // instead of being shown nothing. +}; + +/// Second body byte, only consulted when the result is ERR_LFG_ROLE_CHECK_FAILED +/// (.text:0098E7DE `cmp dl, 1Ch`). Any other value falls through to the plain string. +enum LfgJoinResultDetail +{ + LFG_JOIN_DETAIL_NONE = 0, + LFG_JOIN_DETAIL_TIMEOUT = 3, // -> ERR_LFG_ROLE_CHECK_FAILED_TIMEOUT (string 0x2E9) + LFG_JOIN_DETAIL_NOT_VIABLE = 4, // -> ERR_LFG_ROLE_CHECK_FAILED_NOT_VIABLE (string 0x2EA) }; enum LfgUpdateType @@ -381,6 +761,14 @@ enum LfgUpdateType LFG_UPDATE_STATUS = 15, LFG_UPDATE_GROUP_MEMBER_OFFLINE = 16, LFG_UPDATE_GROUP_DISBAND = 17, + + /// Retail's opening reason for a fresh queue: 257 of 276 observed joins lead with + /// 24 and NONE lead with 6. LFG_UPDATE_JOIN (6) is the re-queue-from-inside-a- + /// dungeon reason, which is why it was the wrong thing to open with. + LFG_UPDATE_JOIN_QUEUE_INITIAL = 24, + /// Sent after SMSG_LFG_PLAYER_REWARD when the run completes. All 283 observed + /// reason-25 bodies carry the same flag tuple. + LFG_UPDATE_DUNGEON_FINISHED = 25, }; enum LfgType @@ -420,8 +808,10 @@ enum LFGSpells enum LFGTimes { - LFG_TIME_ROLECHECK = 45*IN_MILLISECONDS, - LFG_TIME_BOOT = 120, + // SECONDS, not milliseconds: waitForRoleTime is built from time(NULL), + // so 45*IN_MILLISECONDS made a role check expire after 12.5 HOURS. + LFG_TIME_ROLECHECK = 45, + LFG_TIME_BOOT = 30, // retail: 30 s in all 14 observed boot sessions LFG_TIME_PROPOSAL = 45, }; @@ -466,6 +856,11 @@ enum LFGRoleCheckState LFG_ROLECHECK_NO_ROLE = 6 // Someone didn't select a role }; +static_assert(uint8(LFG_ROLECHECK_INITIALITING) == MopLfgPackets::ROLE_CHECK_STATE_INITIATING, + "SMSG_LFG_ROLE_CHECK_UPDATE writes a bit for state == INITIALITING; the value it " + "compares against must track the enum. Both captures the writer is tested on carry " + "state 2 with that bit set."); + /// Role types enum LFGRoles { @@ -476,6 +871,19 @@ enum LFGRoles PLAYER_ROLE_DAMAGE = 0x08 }; +/// Dungeon finder debug modes, driven by `.debug dungeon`. +/// +/// Every relaxation these enable is gated on the queue entry actually containing a game +/// master. Relaxing the matchmaker globally would change how ordinary players match each +/// other while the operator is testing, which is exactly what makes a debug switch +/// untrustworthy. +enum LFGDebugMode +{ + LFG_DEBUG_OFF = 0, // normal matchmaking + LFG_DEBUG_SOLO = 1, // a GM's entry completes alone + LFG_DEBUG_GROUP = 2 // a GM's entry also absorbs whoever else is waiting +}; + /// Role amounts enum LFGRoleCount { @@ -494,7 +902,21 @@ enum LFGTeleportError LFG_TELEPORTERROR_IN_VEHICLE = 3, LFG_TELEPORTERROR_FATIGUE = 4, LFG_TELEPORTERROR_INVALID_LOCATION = 6, - LFG_TELEPORTERROR_CHARMING = 8 + LFG_TELEPORTERROR_CHARMING = 8, + + /// Refusing a teleport because the player is fighting. + /// + /// PROVISIONAL VALUE, and it must not be cited as derived. The client certainly has + /// the message -- ERR_PARTY_LFG_TELEPORT_IN_COMBAT, "You cannot teleport out of the + /// dungeon while in combat.", GlobalString index 712 at .data:00F5FA18 -- and 30 is + /// the case that pushes it in the dispatcher at .text:007AA970. But that dispatcher's + /// neighbouring cases are ERR_INVITE_* and ERR_PARTY_LFG_BOOT_*, so it is the PARTY + /// error space, which may not be the space SMSG_LFG_TELEPORT_DENIED uses. The one + /// captured body of that opcode carries 0x10, which is in neither reading. + /// + /// Harmless today because SendLfgTeleportError is not admitted, so nothing reaches the + /// client. The REFUSAL is the part that matters and that is not in doubt. + LFG_TELEPORTERROR_IN_COMBAT = 30 }; enum DungeonTypes @@ -562,10 +984,27 @@ struct LFGPlayers //TODO: rename to LFGQueueData std::string comments; bool isGroup; - time_t joinedTime; - uint8 neededTanks; - uint8 neededHealers; - uint8 neededDps; + /// The concrete dungeons a RANDOM selection expanded to, kept so a proposal can name + /// one. Empty for a normal queue. + /// + /// dungeonList holds what the player asked for, which for a random queue is the single + /// category row -- that is what the client is shown and what the reward lookup keys on, + /// so it must not be replaced. But a category row is not a place: all 12 TypeID 6 rows + /// in LfgDungeons.dbc carry MapID 0 or 0xFFFFFFFF, so proposing one teleports the group + /// nowhere. The expansion is therefore kept alongside rather than collapsed away. + std::set candidateDungeons; + + // Zeroed: the default constructor left these indeterminate, and needed* decides both + // whether an entry is complete and what the queue advertises to the client. + time_t joinedTime = 0; + /// The queue ticket. Retail never sends 0 in any of the 5291 observed status + /// bodies; it is stable for the life of a queue entry and the client ECHOES IT + /// BACK verbatim in CMSG_LFG_PROPOSAL_RESPONSE and CMSG_LFG_LEAVE, so with 0 the + /// client's own replies cannot be matched to the entry that produced them. + uint32 ticketId = 0; + uint8 neededTanks = 0; + uint8 neededHealers = 0; + uint8 neededDps = 0; LFGPlayers() : currentState(LFG_STATE_NONE), currentRoles(0), isGroup(false) {} LFGPlayers(LFGState state, std::set dungeonSelection, roleMap CurrentRoles, std::string comment, bool IsGroup, time_t JoinedTime, @@ -611,6 +1050,7 @@ struct LFGQueueStatus uint8 neededDps; // amount of dps needed uint32 timeSpentInQueue; // time already spent in the queue uint32 joinTime; // server epoch time when the queue entry was created + uint32 ticketId; // retail's clientQueueId equals the status packet's ticketId }; /// For CMSG_LFG_GET_STATUS, SMSG_LFG_UPDATE_PARTY, and SMSG_LFG_UPDATE_PLAYER @@ -631,6 +1071,7 @@ struct LFGStatusPacketData { uint32 roles = 0; uint32 joinedTime = 0; + uint32 ticketId = 0; uint8 neededTanks = 0; uint8 neededHealers = 0; uint8 neededDps = 0; @@ -640,7 +1081,11 @@ struct LFGStatusPacketData struct LFGGroupStatus //todo: check for this in joinlfg function, not lfgplayers struct { LFGState state; // State of the group - uint32 dungeonID; // ID of the dungeon the group should be in + uint32 dungeonID; // ID of the dungeon the group should be in (the RESOLVED one) + /// The random category the group queued under, or 0 for a direct queue. Kept because + /// SMSG_GROUP_LIST carries BOTH: slot A is the resolved dungeon and slot B the random + /// row. Retail never puts a type-6 entry in slot A. + uint32 randomDungeonID = 0; roleMap playerRoles; // Container holding each player's objectguid and their roles ObjectGuid leaderGuid; // The group leader's object guid @@ -652,17 +1097,39 @@ struct LFGGroupStatus //todo: check for this in joinlfg function, not lfgplayers /// For SMSG_LFG_PROPOSAL_UPDATE struct LFGProposal { - uint32 id; // proposal id - uint32 dungeonID; // dungeon id - LFGProposalState state; // proposal state - uint32 encounters; // encounters done - uint64 groupRawGuid; // group raw guid value - uint64 groupLeaderGuid; // group leader's guid - bool isNew; // is new or old group - roleMap currentRoles; // group player's roles - proposalAnswerMap answers; // answers to a proposal - playerGroupMap groups; // data on which groups players belong/belonged to - time_t joinedQueue; // time from when the players joined the queue + // Every scalar is initialised. groupRawGuid and groupLeaderGuid in particular are + // the only two SendDungeonProposal does not always assign -- it sets them solely on + // the premade path -- yet it READS groupRawGuid to decide whether to set it, and + // CreateDungeonGroup branches on it to choose between reusing an existing group and + // making a new one. Left indeterminate, an all-solo proposal picked its branch from + // whatever was on the stack. + uint32 id = 0; // proposal id + uint32 dungeonID = 0; // dungeon id as QUEUED -- for a random queue this is the + // category row, which is what the client is shown and what + // the reward lookup keys on + + /// The dungeon the group is actually put into. Equals dungeonID for a normal queue. + /// + /// For a random queue it is a concrete member of the expansion, because the category row + /// has no map to teleport to. Split from dungeonID rather than replacing it so the + /// proposal packet and the reward path keep naming the random entry the player chose. + uint32 concreteDungeonID = 0; + + // The m_playerData key this proposal was built from. The queue entry is kept alive + // for the lifetime of the proposal so a failure can put the survivors back, which is + // what the client tells the player happens: ERR_LFG_PROPOSAL_FAILED reads "Someone + // has declined the invite. You have been returned to the front of the queue." + ObjectGuid queueGuid; + time_t createdTime = 0; // for the timeout reaper + LFGProposalState state = LFG_PROPOSAL_INITIATING; // proposal state + uint32 encounters = 0; // encounters done + uint64 groupRawGuid = 0; // group raw guid value + uint64 groupLeaderGuid = 0; // group leader's guid + bool isNew = true; // is new or old group + roleMap currentRoles; // group player's roles + proposalAnswerMap answers; // answers to a proposal + playerGroupMap groups; // data on which groups players belong/belonged to + time_t joinedQueue = 0; // time from when the players joined the queue }; // For SMSG_LFG_PLAYER_REWARD @@ -762,6 +1229,10 @@ class LFGMgr */ void SetPlayerState(ObjectGuid guid, LFGState state); + /// Current `.debug dungeon` mode; LFG_DEBUG_OFF unless an administrator enabled it. + LFGDebugMode GetDebugMode() const { return m_debugMode; } + void SetDebugMode(LFGDebugMode mode) { m_debugMode = mode; } + /** * @brief Set the player's LFG update type * @@ -829,6 +1300,21 @@ class LFGMgr /// Given the ID of a dungeon, spit out its entry uint32 GetDungeonEntry(uint32 ID); + /// The resolved dungeon entry for a group that is in (or heading into) an LFG + /// dungeon, or 0 if it is not an LFG group. SMSG_GROUP_LIST carries this in its + /// LFG block; retail never sends the block with a zero entry. + uint32 GetGroupDungeonEntry(ObjectGuid groupGuid); + + /// The random-category entry a group queued under, or 0. SMSG_GROUP_LIST slot B. + uint32 GetGroupRandomDungeonEntry(ObjectGuid groupGuid); + + /// LFG state of a group, for the SMSG_GROUP_LIST state byte. + LFGState GetGroupLfgState(ObjectGuid groupGuid); + + /// Drop a disbanded group's LFG status. Must run when the Group is torn down, not + /// when its dungeon finishes -- see the note in HandleBossKilled. + void ReleaseGroupLfgStatus(ObjectGuid groupGuid); + /// Return the 5.4.8 LFG status category byte for a dungeon. uint8 GetDungeonCategory(uint32 ID); @@ -846,6 +1332,58 @@ class LFGMgr */ void UpdateNeededRoles(ObjectGuid guid, LFGPlayers* information); + /** + * @brief Fire a proposal for this entry if every role it needs is filled, and + * dequeue it so it cannot be matched or proposed again. + * @return true if a proposal was sent (the entry no longer exists). + */ + bool TryFormGroup(ObjectGuid guid); + + /** + * @brief Cancel a proposal, remove the players responsible, and return everyone else + * to the queue. + * + * @param proposalId the proposal to cancel + * @param culprits players removed from the dungeon finder entirely (the decliner + * and, if they were in a premade, that premade). Empty on timeout, + * where nobody is singled out. + */ + void CancelProposal(uint32 proposalId, std::set const& culprits); + + /// Cancel proposals nobody answered within LFG_TIME_PROPOSAL. + void RemoveOldProposals(); + + /// The decline half of ProposalUpdate: work out who is responsible and cancel. + void DeclineProposal(ObjectGuid plrGuid, LFGProposal* proposal); + + /** + * @brief The key of the queue entry that LISTS this player. + * + * After a merge an absorbed player has no entry under their own guid -- MergeGroups + * folds them into the absorbing entry and erases theirs -- so anything keyed on the + * player's own guid silently misses them. + * + * @return the entry key, or an empty guid if the player is not queued anywhere. + */ + ObjectGuid FindQueueEntryContaining(ObjectGuid plrGuid) const; + + /// Is there a proposal still awaiting this player's answer? Authoritative, unlike + /// the LFG_STATE_PROPOSAL status flag, which several paths can leave stale. + bool HasLiveProposalFor(ObjectGuid plrGuid) const; + + /// Cancel every live proposal listing this player, counting them as the culprit. + /// Used when they leave the finder while a proposal is still open. + void CancelProposalsFor(ObjectGuid plrGuid); + + /** + * @brief Take a single player out of whichever queue entry holds them, recomputing + * that entry's needed roles, and drop the entry if it is left empty. + */ + void RemovePlayerFromQueue(ObjectGuid plrGuid); + + /// Does this queue entry contain at least one game master? Scopes `.debug dungeon`. + bool EntryHasGameMaster(LFGPlayers const* entry) const; + /** * @brief Add the player or group to the Dungeon Finder queue * @@ -872,6 +1410,10 @@ class LFGMgr /// Send a periodic status update for queued players void SendQueueStatus(); + void SendQueueStatusFor(ObjectGuid queueGuid, time_t timeNow); + + /// Non-zero, stable per queue entry, monotonic. See LFGPlayers::ticketId. + uint32 AllocateTicketId() { return ++m_nextTicketId; } /// Role-Related Functions @@ -885,7 +1427,16 @@ class LFGMgr void PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles); /// Make sure role selections are okay - bool ValidateGroupRoles(roleMap groupMap); + bool ValidateGroupRoles(roleMap groupMap, std::set const& dungeonList); + + /** + * @brief Can every player fill exactly one of the roles they ticked, within the + * role counts the dungeon's own DBC row asks for? + * + * Handles multi-role selections: the client offers four independent checkboxes, + * so a mask carrying tank|damage is a player willing to be either. + */ + bool RolesAreValidForDungeons(roleMap const& roles, std::set const& dungeonList); /// Proposal-Related Functions @@ -919,7 +1470,7 @@ class LFGMgr bool HasLeaderFlag(roleMap const& roles); /// Compares two groups/players to see if their role combinations are compatible - bool RoleMapsAreCompatible(LFGPlayers* groupOne, LFGPlayers* groupTwo); + bool RoleMapsAreCompatible(LFGPlayers* groupOne, LFGPlayers* groupTwo, std::set const& compatibleDungeons); /// Checks whether or not two combinations of players/groups are on the same team (alliance/horde) bool MatchesAreOfSameTeam(LFGPlayers* groupOne, LFGPlayers* groupTwo); @@ -949,7 +1500,7 @@ class LFGMgr void MergeGroups(ObjectGuid guidOne, ObjectGuid guidTwo, std::set compatibleDungeons); /// Send a proposal to each member of a group - void SendDungeonProposal(LFGPlayers* lfgGroup); + void SendDungeonProposal(ObjectGuid queueGuid, LFGPlayers* lfgGroup); /// Tell a group member that someone else just confirmed their role void SendRoleChosen(ObjectGuid plrGuid, ObjectGuid confirmedGuid, uint8 roles); @@ -961,7 +1512,7 @@ class LFGMgr void SendLfgUpdate(ObjectGuid plrGuid, LFGPlayerStatus status, bool isGroup); /// Send SMSG_LFG_JOIN_RESULT - void SendLfgJoinResult(ObjectGuid plrGuid, LfgJoinResult result, LFGState state, partyForbidden const& lockedDungeons); + void SendLfgJoinResult(ObjectGuid plrGuid, LfgJoinResult result, uint8 detail, partyForbidden const& lockedDungeons); /// Get rid of expired role checks void RemoveOldRoleChecks(); @@ -976,6 +1527,7 @@ class LFGMgr /// General info related to joining / leaving the dungeon finder playerData m_playerData; queueSet m_queueSet; + uint32 m_nextTicketId; /// Dungeon Finder Status for players playerStatusMap m_playerStatusMap; @@ -997,6 +1549,7 @@ class LFGMgr /// Proposal information uint32 m_proposalId; + LFGDebugMode m_debugMode = LFG_DEBUG_OFF; proposalMap m_proposalMap; }; diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index b3bc9ed03..6ce59156b 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -23,6 +23,9 @@ * and lore are copyrighted by Blizzard Entertainment, Inc. */ +#include +#include + #include "DBCEnums.h" #include "DBCStores.h" #include "DBCStructure.h" @@ -53,15 +56,22 @@ void LFGMgr::PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles) return; // no role check map found } - LFGRoleCheck roleCheck = it->second; + // A REFERENCE, not a copy. This was `LFGRoleCheck roleCheck = it->second;`, so + // every `roleCheck.currentRoles[plrGuid] = roles` below landed in a temporary that + // was discarded on return -- no member's answer was ever recorded, and a party of + // two or more could never complete its role check no matter what anyone clicked. + LFGRoleCheck& roleCheck = it->second; bool roleChosen = roleCheck.state != LFG_ROLECHECK_DEFAULT && plrGuid; if (!plrGuid) { roleCheck.state = LFG_ROLECHECK_ABORTED; // aborted if anyone cancels during role check } - else if (roles < PLAYER_ROLE_TANK) // kind of a sanity check- the client shouldn't allow this to happen + else if (!(roles & (PLAYER_ROLE_TANK | PLAYER_ROLE_HEALER | PLAYER_ROLE_DAMAGE))) { + // The mask must name at least one real role. Testing `roles < PLAYER_ROLE_TANK` + // only rejected 0 and a bare LEADER bit; it accepted any unknown high bit as a + // valid answer, which then matched no role anywhere downstream. roleCheck.state = LFG_ROLECHECK_NO_ROLE; } else @@ -80,7 +90,7 @@ void LFGMgr::PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles) if (allRolesChosen) // meaning that everyone confirmed their roles { - roleCheck.state = ValidateGroupRoles(roleCheck.currentRoles) ? LFG_ROLECHECK_FINISHED : LFG_ROLECHECK_MISSING_ROLE; + roleCheck.state = ValidateGroupRoles(roleCheck.currentRoles, roleCheck.dungeonList) ? LFG_ROLECHECK_FINISHED : LFG_ROLECHECK_MISSING_ROLE; } } @@ -121,7 +131,7 @@ void LFGMgr::PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles) default: if (roleCheck.leaderGuidRaw == guidBuff.GetRawValue()) { - SendLfgJoinResult(guidBuff, ERR_LFG_ROLE_CHECK_FAILED, LFG_STATE_ROLECHECK, nullForbidden); + SendLfgJoinResult(guidBuff, ERR_LFG_ROLE_CHECK_FAILED, uint8(roleCheck.state), nullForbidden); } SetPlayerUpdateType(guidBuff, LFG_UPDATE_ROLECHECK_FAILED); SendLfgUpdate(guidBuff, GetPlayerStatus(guidBuff), true); @@ -131,7 +141,13 @@ void LFGMgr::PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles) if (roleCheck.state == LFG_ROLECHECK_FINISHED) { - LFGPlayers* queueInfo = GetPlayerOrPartyData(groupGuid); + LFGPlayers* queueInfo = GetPlayerOrPartyData(groupGuid); + if (!queueInfo) + { + m_roleCheckMap.erase(groupGuid); + return; + } + queueInfo->currentState = LFG_STATE_QUEUED; queueInfo->currentRoles = roleCheck.currentRoles; queueInfo->joinedTime = time(NULL); @@ -139,6 +155,10 @@ void LFGMgr::PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles) m_playerData[groupGuid] = *queueInfo; AddToQueue(groupGuid); + + // The check is resolved; leaving it in the map makes RemoveOldRoleChecks expire + // an already-queued party and tear its queue entry back down. + m_roleCheckMap.erase(groupGuid); } else if (roleCheck.state != LFG_ROLECHECK_INITIALITING) { @@ -158,39 +178,90 @@ void LFGMgr::PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles) } } -bool LFGMgr::ValidateGroupRoles(roleMap groupMap) +bool LFGMgr::ValidateGroupRoles(roleMap groupMap, std::set const& dungeonList) { if (groupMap.empty()) // sanity check { return false; } - uint8 tankCount = 0, dpsCount = 0, healCount = 0; + // This used to assert only that every member had picked exactly one of tank/healer/ + // damage, which failed two ways at once: a member who ticked tank AND damage matched + // no case and sank the whole party's role check, while a party of five tanks passed + // it and then jammed the queue because no dungeon has five tank slots. + // + // Asking whether the party can be assigned to the dungeon's actual role counts covers + // both, and covers scenarios and raid finder, whose compositions are not 1/1/3. + return RolesAreValidForDungeons(groupMap, dungeonList); +} - for (roleMap::iterator it = groupMap.begin(); it != groupMap.end(); ++it) +/** + * @brief The dungeon a proposal should actually put the group into. + * + * A normal queue names a real dungeon and this returns it unchanged. A RANDOM queue names a + * category, and a category is not a place: all 12 TypeID 6 rows in LfgDungeons.dbc carry MapID + * 0 or 0xFFFFFFFF. Proposing one sent the group to a plain teleport failure, or -- for the four + * carrying 0 -- silently to Eastern Kingdoms. + * + * The category row is excluded from its own expansion. Group_ID 33, behind Random Hour of + * Twilight Heroic, has exactly ONE member and that member is the category row itself, so + * without the exclusion that random would still propose an unrunnable row. + * + * Untranslatable tiers are excluded for the same reason JoinLFG refuses them at admission: a + * row whose DifficultyID has no internal mode cannot be entered at the tier it claims. + * + * @return a concrete dungeon id, or 0 when nothing behind the selection is runnable. + */ +static uint32 PickConcreteDungeon(uint32 queuedDungeonId, std::set const& candidates) +{ + LfgDungeonsEntry const* queued = sLfgDungeonsStore.LookupEntry(queuedDungeonId); + if (!queued) { - uint8 withoutLeader = it->second; - withoutLeader &= ~PLAYER_ROLE_LEADER; + return 0; + } - switch (withoutLeader) + if (queued->TypeID != LFG_TYPE_RANDOM_DUNGEON) + { + return queuedDungeonId; // already a real dungeon + } + + // Collect every runnable member, then pick one at random. + // + // This used to return the first match. candidates is a std::set, which is + // ordered ascending, so "random dungeon" deterministically produced the LOWEST + // dungeon id in the category every single time -- the same instance on every queue. + std::vector runnable; + for (std::set::const_iterator it = candidates.begin(); it != candidates.end(); ++it) + { + if (*it == queuedDungeonId) { - case PLAYER_ROLE_TANK: - ++tankCount; - break; - case PLAYER_ROLE_HEALER: - ++healCount; - break; - case PLAYER_ROLE_DAMAGE: - ++dpsCount; - break; + continue; // the category cannot host itself + } + + LfgDungeonsEntry const* candidate = sLfgDungeonsStore.LookupEntry(*it); + if (!candidate || candidate->TypeID == LFG_TYPE_RANDOM_DUNGEON) + { + continue; } + + if (ToInternalDifficulty(candidate->DifficultyID) < 0) + { + continue; + } + + runnable.push_back(candidate->ID); } - return (tankCount + dpsCount + healCount == groupMap.size()) ? true : false; + if (runnable.empty()) + { + return 0; + } + + return runnable[urand(0, uint32(runnable.size()) - 1)]; } //todo: remove from queue, update queue average settings -void LFGMgr::SendDungeonProposal(LFGPlayers* lfgGroup) +void LFGMgr::SendDungeonProposal(ObjectGuid queueGuid, LFGPlayers* lfgGroup) { ++m_proposalId; // increment number to make a new proposal id @@ -203,8 +274,45 @@ void LFGMgr::SendDungeonProposal(LFGPlayers* lfgGroup) newProposal.encounters = 0; // todo: check if group has already started a dungeon and are looking for another plr newProposal.currentRoles = lfgGroup->currentRoles; newProposal.dungeonID = *dItr; + + // The dungeon the group is actually put into. + // + // For a normal queue that is the queued row. For a RANDOM one it cannot be: every TypeID 6 + // row in LfgDungeons.dbc carries MapID 0 or 0xFFFFFFFF, so proposing the category itself + // teleports the group nowhere -- 4 of the 12 silently to Eastern Kingdoms and the other 8 to + // a plain failure. A concrete member of the expansion is chosen instead, while dungeonID + // keeps naming the random entry for the proposal packet and the reward lookup. + newProposal.concreteDungeonID = PickConcreteDungeon(*dItr, lfgGroup->candidateDungeons); + if (!newProposal.concreteDungeonID) + { + // Nothing runnable behind the category. Do not build a proposal that cannot complete: + // the group would be formed, torn out of its previous groups and then left standing. + sLog.outError("LFG SendDungeonProposal: random dungeon %u expanded to no runnable " + "member; refusing to propose.", *dItr); + return; + } + newProposal.isNew = true; newProposal.joinedQueue = lfgGroup->joinedTime; + newProposal.createdTime = time(NULL); + + // Which queue entry this came from, so a failure can put the survivors back. Passed + // in rather than recovered by scanning m_playerData for a matching address: the + // caller already knows the key, and identifying a map entry by the address of its + // value is the kind of thing that quietly stops working the first time anyone copies + // the struct. + newProposal.queueGuid = queueGuid; + + { + std::ostringstream avail; + for (std::set::const_iterator it = lfgGroup->dungeonList.begin(); + it != lfgGroup->dungeonList.end(); ++it) + { + avail << (it == lfgGroup->dungeonList.begin() ? "" : ",") << *it; + } + DEBUG_LOG("LFG SendDungeonProposal: entry dungeons={%s} -> chose %u (entry 0x%08X)", + avail.str().c_str(), newProposal.dungeonID, GetDungeonEntry(newProposal.dungeonID)); + } bool premadeGroup = IsProposalSameGroup(newProposal); @@ -252,9 +360,21 @@ void LFGMgr::SendDungeonProposal(LFGPlayers* lfgGroup) } newProposal.answers[plrGuid] = LFG_ANSWER_PENDING; + } - // then send SMSG_LFG_PROPOSAL_UPDATE - pPlayer->GetSession()->SendLfgProposalUpdate(newProposal); + // Sent only once the proposal is COMPLETE. + // + // This used to sit inside the loop above, which is still filling `groups` and + // `answers`. Since the packet serialises those maps, every recipient except the last + // one received an opening proposal that omitted the members added after them -- so + // the ready popup showed an incomplete group until somebody answered. + for (roleMap::const_iterator it = lfgGroup->currentRoles.begin(); + it != lfgGroup->currentRoles.end(); ++it) + { + if (Player* pMember = sObjectAccessor.FindPlayer(it->first)) + { + pMember->GetSession()->SendLfgProposalUpdate(newProposal); + } } // then if group guid is set, call Group::SetAsLfgGroup() @@ -288,8 +408,16 @@ void LFGMgr::SendDungeonProposal(LFGPlayers* lfgGroup) bool LFGMgr::IsProposalSameGroup(LFGProposal const& proposal) { + // True only when EVERY member is in the SAME existing group. + // + // This used to skip ungrouped players entirely, so a two-man party matched with + // three solo queuers returned true -- the proposal was then treated as a premade + // and CreateDungeonGroup reused the party's group without ever adding the solos. + // It also returned true when nobody was grouped at all, because isSameGroup started + // true and had no way to become false. bool firstLoop = true; bool isSameGroup = true; + bool anyGrouped = false; ObjectGuid priorGroupGuid; @@ -307,31 +435,65 @@ bool LFGMgr::IsProposalSameGroup(LFGProposal const& proposal) continue; } - if (Group* pGroup = pPlayer->GetGroup()) + Group* pGroup = pPlayer->GetGroup(); + if (!pGroup) { - ObjectGuid grpGuid = pGroup->GetObjectGuid(); + return false; // an ungrouped member means this is not one existing group + } - if (firstLoop) - { - priorGroupGuid = grpGuid; - firstLoop = false; - } - else + anyGrouped = true; + ObjectGuid grpGuid = pGroup->GetObjectGuid(); + + if (firstLoop) + { + priorGroupGuid = grpGuid; + firstLoop = false; + } + else if (grpGuid != priorGroupGuid) + { + isSameGroup = false; + } + } + + return anyGrouped && isSameGroup; +} + +// From a CMSG_LFG_PROPOSAL_RESPONSE call +/// A decline cancels the proposal, but it does NOT eject everyone. +/// +/// The client states all three outcomes plainly: +/// ERR_LFG_PROPOSAL_FAILED "Someone has declined the invite. You have been +/// returned to the front of the queue." +/// ERR_LFG_PROPOSAL_DECLINED_SELF "You have been removed from the queue because you +/// did not accept the invitation." +/// ERR_LFG_PROPOSAL_DECLINED_PARTY "...because someone in your party did not accept." +/// +/// So the decliner leaves, their premade leaves with them, and everyone else is +/// requeued. An earlier version of this removed everyone, which is why the queue entry +/// is now kept alive for the lifetime of the proposal -- there has to be something left +/// to put people back into. +void LFGMgr::DeclineProposal(ObjectGuid plrGuid, LFGProposal* proposal) +{ + std::set culprits; + culprits.insert(plrGuid); + + // A premade is removed alongside the member who declined for it. + playerGroupMap::const_iterator declinerGroup = proposal->groups.find(plrGuid); + if (declinerGroup != proposal->groups.end() && declinerGroup->second) + { + for (playerGroupMap::const_iterator it = proposal->groups.begin(); + it != proposal->groups.end(); ++it) + { + if (it->second == declinerGroup->second) { - if (isSameGroup) - { - if (grpGuid != priorGroupGuid) - { - isSameGroup = false; - } - } + culprits.insert(it->first); } } } - return isSameGroup; + + CancelProposal(proposal->id, culprits); } -// From a CMSG_LFG_PROPOSAL_RESPONSE call void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted) { //note: create a group here if it doesn't exist and everyone accepted proposal @@ -342,16 +504,29 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted return; } + // Only a participant may answer. + // + // m_proposalId is a plain incrementing counter, so an id is trivially guessable. + // Without this check, writing to proposal->answers INSERTED the caller, and a + // `false` answer from any logged-in player cancelled a group they had nothing to do + // with -- clearing the real members out of the queue. + if (proposal->answers.find(plrGuid) == proposal->answers.end()) + { + sLog.outError("LFG: %s answered proposal %u they are not part of.", + plrGuid.GetString().c_str(), proposalID); + return; + } + bool allOkay = true; // true if everyone answered LFG_ANSWER_AGREE // Update answer map to given value LFGProposalAnswer plrAnswer = (LFGProposalAnswer)accepted; proposal->answers[plrGuid] = plrAnswer; - // If the player declined, the proposal is over if (plrAnswer == LFG_ANSWER_DENY) { - ProposalDeclined(plrGuid, proposal); + DeclineProposal(plrGuid, proposal); + return; } for (proposalAnswerMap::iterator it = proposal->answers.begin(); it != proposal->answers.end(); ++it) @@ -395,7 +570,15 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted Player* pProposalPlayer = sObjectAccessor.FindPlayer(proposalPlrGuid); if (!pProposalPlayer) { - continue; + // Accepted, then logged out before the last answer arrived. allOkay still + // passed because their answer was already AGREE, and skipping them here + // built a SHORT group and teleported it while groupStatus recorded a role + // for someone who was never added. Cancel instead: the absent member is the + // culprit and everyone else goes back to the queue. + std::set absent; + absent.insert(proposalPlrGuid); + CancelProposal(proposal->id, absent); + return; } if (sendProposalUpdate) @@ -413,23 +596,48 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted LFGPlayerStatus proposalPlrStatus = GetPlayerStatus(proposalPlrGuid); proposalPlrStatus.updateType = LFG_UPDATE_GROUP_FOUND; - if (pProposalPlayer->GetGroup()) - { - SendLfgUpdate(proposalPlrGuid, proposalPlrStatus, true); - RemoveFromQueue(pProposalPlayer->GetGroup()->GetObjectGuid()); // not the best way to handle this - } - else - { - SendLfgUpdate(proposalPlrGuid, proposalPlrStatus, false); - RemoveFromQueue(proposalPlrGuid); - } + // ONE key, used for both packets. + // + // The queue entry is owned by the player's CURRENT group if they have one -- + // that is the same test JoinLFG used to key m_playerData -- so the GROUP_FOUND + // and the LEAVE that follows it must both be sent under that key. The LEAVE used + // to be sent TWICE, once in each form, on the theory that one of them would + // match. It cannot help: SendLfgUpdate picks requesterGuid from the isGroup flag, + // so the wrong-form copy names a queue the client is not tracking, and at accept + // time GetGroup() is still the player's OLD party rather than the LFG group being + // formed -- so the group-form copy could name a third guid again. + bool const queueIsGroupOwned = pProposalPlayer->GetGroup() != nullptr; + + SendLfgUpdate(proposalPlrGuid, proposalPlrStatus, queueIsGroupOwned); + RemoveFromQueue(queueIsGroupOwned ? pProposalPlayer->GetGroup()->GetObjectGuid() + : proposalPlrGuid); proposalPlrStatus.updateType = LFG_UPDATE_LEAVE; - SendLfgUpdate(proposalPlrGuid, proposalPlrStatus, false); - SendLfgUpdate(proposalPlrGuid, proposalPlrStatus, true); + SendLfgUpdate(proposalPlrGuid, proposalPlrStatus, queueIsGroupOwned); } CreateDungeonGroup(proposal); + + // Tear the queue entry down. TryFormGroup deliberately KEEPS it alive for the + // lifetime of the proposal so a decline or timeout can put the survivors back -- + // but on success nobody put it back, so it sat in m_playerData forever with + // currentState LFG_STATE_PROPOSAL, and every member's stored status stayed at + // LFG_STATE_PROPOSAL too. JoinLFG refuses that state, so a player who successfully + // entered a dungeon could never queue again until relog. Observed live: five + // rejected CMSG_LFG_JOIN attempts after one successful proposal. + ObjectGuid const queueGuid = proposal->queueGuid; + for (roleMap::const_iterator it = proposal->currentRoles.begin(); + it != proposal->currentRoles.end(); ++it) + { + // They are in the dungeon now, not queued. TeleportToDungeon sets this too for + // the players it actually moves, but a member whose teleport was denied must not + // be left reading LFG_STATE_PROPOSAL either. + SetPlayerState(it->first, LFG_STATE_IN_DUNGEON); + } + + m_queueSet.erase(queueGuid); + m_playerData.erase(queueGuid); + m_proposalMap.erase(proposal->id); } @@ -452,137 +660,195 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) return; } - Group* pGroup = nullptr; + // Rewritten. The previous version had four independent defects on this one path: + // + // - The leader search looped over every role-flagged member calling Group::Create + // with no break, so two merged premades carrying two LEADER bits ran Create + // twice on one object. Each call does its own GenerateGroupLowGuid plus an + // INSERT INTO groups in its own transaction, orphaning the first group id and + // stranding that id's group_member rows. + // - If a leader bit was set but every leader-flagged player was offline, Create + // never ran while AddMember still did -- building a group with id 0 and an empty + // leader guid, which was then inserted into m_groupSet. + // - The existing-group branch called no AddMember at all, so the commonest LFD + // composition (one premade plus solo queuers) dequeued the solos, told them a + // group was found, and never put them in one. + // - Nothing registered the group with ObjectMgr, so GetGroupById could not find + // it, it leaked at shutdown, and the boot path called RemoveGroup on a group + // that had never been added. + // + // Resolve the leader ONCE, up front, and require them to be online. + ObjectGuid leaderGuid; - if (!proposal->groupRawGuid) + // With `.debug dungeon` active a game master leads the dungeon regardless of who + // carries the LEADER bit, so the operator always has control of the group they are + // testing. Checked first, so it wins outright. + if (m_debugMode != LFG_DEBUG_OFF) { - bool leaderIsSet = false; - bool leaderRoleIsSet = HasLeaderFlag(proposal->currentRoles); - ObjectGuid leaderGuid; - - pGroup = new Group(); - - for (playerGroupMap::iterator it = proposal->groups.begin(); it != proposal->groups.end(); ++it) + for (roleMap::const_iterator it = proposal->currentRoles.begin(); + it != proposal->currentRoles.end(); ++it) { - // remove plr from group w/ guid it->second - // set leader on first loop, then set leaderisset to true - ObjectGuid pGroupPlrGuid = it->first; - Player* pGroupPlr = sObjectAccessor.FindPlayer(pGroupPlrGuid); - - if (pGroupPlr && it->second) + Player* pPlayer = sObjectAccessor.FindPlayer(it->first); + if (pPlayer && pPlayer->GetSession() && + pPlayer->GetSession()->GetSecurity() >= SEC_GAMEMASTER) { - Group* existingGroup = pGroupPlr->GetGroup(); - if (existingGroup) - { - existingGroup->RemoveMember(pGroupPlrGuid, 0); - } + leaderGuid = it->first; + break; } + } + } - if (pGroupPlr && !leaderIsSet) - { - bool currentPlrIsLeader = false; - if (leaderRoleIsSet) - { - for (roleMap::iterator itr = proposal->currentRoles.begin(); itr != proposal->currentRoles.end(); ++itr) - { - if (itr->second & PLAYER_ROLE_LEADER) - { - leaderGuid = itr->first; - Player* leaderRef = sObjectAccessor.FindPlayer(leaderGuid); - - if (leaderRef) - { - pGroup->Create(leaderRef->GetObjectGuid(), leaderRef->GetName()); - currentPlrIsLeader = (pGroupPlrGuid == leaderGuid); - } - } - } - } - else - { - pGroup->Create(pGroupPlrGuid, pGroupPlr->GetName()); - } - - if (!currentPlrIsLeader) - { - pGroup->AddMember(pGroupPlrGuid, pGroupPlr->GetName()); - } - - leaderIsSet = true; - } - else if (leaderIsSet && pGroupPlr && pGroupPlrGuid != leaderGuid) - { - pGroup->AddMember(pGroupPlrGuid, pGroupPlr->GetName()); - } + for (roleMap::const_iterator it = proposal->currentRoles.begin(); + !leaderGuid && it != proposal->currentRoles.end(); ++it) + { + if ((it->second & PLAYER_ROLE_LEADER) && sObjectAccessor.FindPlayer(it->first)) + { + leaderGuid = it->first; + break; } - pGroup->SetAsLfgGroup(); } - else + + // Looked up BEFORE anything is created. This used to sit after group creation, so + // an unknown dungeon id returned having already new'd a Group, run Create (a group + // id plus an INSERT INTO groups) and registered it with ObjectMgr -- leaking the + // object and stranding its rows, with the proposal also left in m_proposalMap. + // The CONCRETE dungeon: proposal->dungeonID may be a random category, which has no map. + // Older proposals predating the split carry 0 here, so fall back rather than refuse. + uint32 const runDungeonId = proposal->concreteDungeonID ? proposal->concreteDungeonID + : proposal->dungeonID; + LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(runDungeonId); + if (!dungeon) { - Player* pGroupLeader = sObjectAccessor.FindPlayer(ObjectGuid(proposal->groupLeaderGuid)); + return; + } + + Group* pGroup = nullptr; - // Check if the group leader was found before accessing their group + if (proposal->groupRawGuid) + { + // Reuse the premade group the proposal was built around. + Player* pGroupLeader = sObjectAccessor.FindPlayer(ObjectGuid(proposal->groupLeaderGuid)); if (pGroupLeader) { pGroup = pGroupLeader->GetGroup(); } - else - { - // Log that the group leader is missing and fall back to creating a new group - // In the future, we should determine the right actions for this scenario. - // LOG_ERROR("LFGMgr::CreateDungeonGroup", "Group leader with GUID %u not found. Creating new group.", proposal->groupLeaderGuid); - // Attempt to create a new group using the first available player in the proposal group - if (!proposal->groups.empty()) + // The stored leader may have logged out between proposal and acceptance. Fall + // back to any online member still in that same group. + if (!pGroup) + { + for (playerGroupMap::const_iterator it = proposal->groups.begin(); + it != proposal->groups.end(); ++it) { - ObjectGuid fallbackLeaderGuid = proposal->groups.begin()->first; - Player* fallbackLeader = sObjectAccessor.FindPlayer(fallbackLeaderGuid); - - if (fallbackLeader) + if (it->second.GetRawValue() != proposal->groupRawGuid) { - pGroup = new Group(); - pGroup->Create(fallbackLeader->GetObjectGuid(), fallbackLeader->GetName()); - pGroup->SetAsLfgGroup(); + continue; + } - // Add remaining members to the new group - for (playerGroupMap::iterator it = proposal->groups.begin(); it != proposal->groups.end(); ++it) + if (Player* pMember = sObjectAccessor.FindPlayer(it->first)) + { + pGroup = pMember->GetGroup(); + if (pGroup) { - ObjectGuid pGroupPlrGuid = it->first; - if (pGroupPlrGuid != fallbackLeaderGuid) - { - Player* pGroupPlr = sObjectAccessor.FindPlayer(pGroupPlrGuid); - if (pGroupPlr) - { - pGroup->AddMember(pGroupPlrGuid, pGroupPlr->GetName()); - } - } + break; } } - else + } + } + } + + if (!pGroup) + { + // No group to reuse: build one. The leader is whoever carries the LEADER bit + // and is online, else the first online member. + if (!leaderGuid) + { + for (playerGroupMap::const_iterator it = proposal->groups.begin(); + it != proposal->groups.end(); ++it) + { + if (sObjectAccessor.FindPlayer(it->first)) { - // If no valid players are found, we return without proceeding - // In the future, we should determine the right actions for this scenario. - // LOG_ERROR("LFGMgr::CreateDungeonGroup", "No valid players found to create a fallback group."); - return; + leaderGuid = it->first; + break; } } - else + } + + Player* pLeader = sObjectAccessor.FindPlayer(leaderGuid); + if (!pLeader) + { + return; // everyone went offline; nothing to build + } + + // Detach from any prior group BEFORE creating, so Create does not run against a + // player their old group still lists. + // + // Player::RemoveFromGroup, not Group::RemoveMember directly: pulling a member + // out of a two-man group makes RemoveMember Disband it, and Disband does not + // delete the object or unregister it. The helper is the codebase's own + // convention for exactly this and handles RemoveGroup plus delete. + for (playerGroupMap::const_iterator it = proposal->groups.begin(); + it != proposal->groups.end(); ++it) + { + Player* pMember = sObjectAccessor.FindPlayer(it->first); + if (pMember && pMember->GetGroup()) { - // Log if there are no players in the proposal groups map - // In the future, we should determine the right actions for this scenario. - // LOG_ERROR("LFGMgr::CreateDungeonGroup", "Proposal groups map is empty, cannot create fallback group."); - return; + Player::RemoveFromGroup(pMember->GetGroup(), it->first); } } + + pGroup = new Group(); + if (!pGroup->Create(pLeader->GetObjectGuid(), pLeader->GetName())) + { + delete pGroup; + return; + } + + pGroup->SetAsLfgGroup(); + + // A dungeon whose composition exceeds a party must be a RAID before anyone is + // added. Group::IsFull caps a normal party at MAX_GROUP_SIZE, and AddMember just + // returns false past that -- so a raid-finder proposal (2/6/17 = 25) silently + // completed as a five-man while the other twenty were told a group had been + // found, never added, and never teleported. + if (dungeon->Count_tank + dungeon->Count_healer + dungeon->Count_damage > MAX_GROUP_SIZE) + { + pGroup->ConvertToRaid(); + } + + sObjectMgr.AddGroup(pGroup); } - // Set dungeon difficulty for group - LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(proposal->dungeonID); - if (!dungeon || !pGroup) + // Everyone in the proposal who is not already in this group joins it. That covers + // both paths: a freshly created group needs every non-leader added, and a reused + // premade needs the solo queuers that were matched into it. + ObjectGuid const groupGuid = pGroup->GetObjectGuid(); + for (playerGroupMap::const_iterator it = proposal->groups.begin(); + it != proposal->groups.end(); ++it) { - return; + Player* pMember = sObjectAccessor.FindPlayer(it->first); + if (!pMember || pGroup->IsMember(it->first)) + { + continue; + } + + if (Group* existing = pMember->GetGroup()) + { + Player::RemoveFromGroup(existing, it->first); + } + + if (!pGroup->AddMember(it->first, pMember->GetName())) + { + // Ignoring this return is how the raid case failed silently. Say so. + sLog.outError("LFG: could not add %s to dungeon group %u (full at %u members).", + it->first.GetString().c_str(), pGroup->GetId(), + pGroup->GetMembersCount()); + } } + // `dungeon` is the lookup made at the top of this function, before any group was + // created -- it is not re-fetched here. + // // LfgDungeons.dbc carries a RAW client DifficultyID. Casting it straight to // Difficulty made LFG normal (id 1) select internal mode 1 -- HEROIC -- and LFG // heroic (id 2) select mode 2, CHALLENGE. That value does not stay in the session: @@ -647,12 +913,19 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) pGroup->SetDungeonDifficulty(Difficulty(dungeonMode)); } - // Add group to our group set and group map, then teleport to the dungeon - ObjectGuid groupGuid = pGroup->GetObjectGuid(); + // Add group to our group set and group map, then teleport to the dungeon. + // groupGuid is the one taken above; do not shadow it. LFGGroupStatus groupStatus(LFG_STATE_IN_DUNGEON, dungeon->ID, proposal->currentRoles, pGroup->GetLeaderGuid()); + // Only when the two differ was this a random queue; proposal->dungeonID is the row the + // player actually picked, which for a random IS the category. + if (proposal->concreteDungeonID && proposal->dungeonID != proposal->concreteDungeonID) + { + groupStatus.randomDungeonID = proposal->dungeonID; + } m_groupSet.insert(groupGuid); m_groupStatusMap[groupGuid] = groupStatus; + TeleportToDungeon(dungeon->ID, pGroup); pGroup->SendUpdate(); @@ -693,6 +966,9 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) } else { + sLog.outError("LFG TeleportToDungeon: no map entrance trigger for map %u " + "(dungeon %u) -- areatrigger_teleport has no row targeting it", + mapID, dungeonID); err = LFG_TELEPORTERROR_INVALID_LOCATION; } } @@ -717,6 +993,19 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) { plrErr = LFG_TELEPORTERROR_IN_VEHICLE; } + // NO combat check here, deliberately. + // + // TeleportToDungeon also runs from CreateDungeonGroup, where a proposal has + // just been accepted and the group formed. Refusing one member there teleports + // everyone else in and strands that player -- still in the group, still set to + // LFG_STATE_IN_DUNGEON, and with no feedback at all, because + // SMSG_LFG_TELEPORT_DENIED is not admitted. A proposal accept is a mandatory + // group form, and the client's own message for this + // (ERR_PARTY_LFG_TELEPORT_IN_COMBAT) is about teleporting OUT of a dungeon, + // not about being placed into one. + // + // The voluntary paths are still covered: CMSG_LFG_TELEPORT and the leave + // teleport both go through TeleportPlayer, which keeps its own combat guard. lockedDungeons = FindRandomDungeonsNotForPlayer(pGroupPlr); if (lockedDungeons.find(dungeon->Entry()) != lockedDungeons.end()) @@ -739,10 +1028,14 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) if (err != LFG_TELEPORTERROR_OK) { + sLog.outError("LFG TeleportToDungeon: %s DENIED, dungeon %u map %u, group error %u", + pGroupPlr->GetName(), dungeonID, mapID, uint32(err)); pGroupPlr->GetSession()->SendLfgTeleportError(err); } else if (plrErr != LFG_TELEPORTERROR_OK) { + sLog.outError("LFG TeleportToDungeon: %s DENIED, dungeon %u map %u, player error %u", + pGroupPlr->GetName(), dungeonID, mapID, uint32(plrErr)); pGroupPlr->GetSession()->SendLfgTeleportError(plrErr); } else @@ -770,6 +1063,30 @@ void LFGMgr::TeleportPlayer(Player* pPlayer, bool out) return; } + // Never move a player who is fighting, in EITHER direction. + // + // Without this the dropdown was an instant combat escape -- pull a pack, teleport + // out, and the fight is simply over -- and Leave Instance Group yanked the player + // out mid-pull, leaving the rest of the group in a fight they did not choose to + // take alone. The client agrees this is refusable: it ships the message for it + // (ERR_PARTY_LFG_TELEPORT_IN_COMBAT, "You cannot teleport out of the dungeon while + // in combat."). + // + // Deliberately covers `in` as well. Teleporting INTO a dungeon while fighting + // something outside it strands the mob and drops the player into an instance still + // flagged in combat. + // + // This guard sits in TeleportPlayer rather than at the call sites so that the + // dropdown (CMSG_LFG_TELEPORT) and the leave path (CMSG_GROUP_DISBAND) are both + // covered by one check that cannot be forgotten by a third caller. + if (pPlayer->IsInCombat()) + { + DEBUG_LOG("LFG TeleportPlayer: %s refused (%s) -- in combat", + pPlayer->GetName(), out ? "out" : "in"); + pPlayer->GetSession()->SendLfgTeleportError((uint8)LFG_TELEPORTERROR_IN_COMBAT); + return; + } + // Get dungeon info and then teleport the player out if applicable if (out) { @@ -778,7 +1095,23 @@ void LFGMgr::TeleportPlayer(Player* pPlayer, bool out) { pPlayer->TeleportToBGEntryPoint(); } + return; } + + // Teleport back IN. + // + // This branch did not exist: TeleportPlayer only ever handled `out`, so the dropdown's + // "Teleport to dungeon" resolved the group and the status and then fell off the end of + // the function doing nothing. Observed live -- a player who ported out could not get + // back, which is worse than not offering the option at all. + // + // TeleportToDungeon is the same routine the proposal uses on group creation. It moves + // only members whose map is not already the dungeon's, so calling it for the whole + // group moves exactly the one player who left, and it carries the dead / falling / + // in-vehicle checks and the SMSG_LFG_TELEPORT_DENIED replies with it. It also prefers + // the group leader's position when the leader is already inside, which is what puts a + // returning player back with the group rather than at the entrance. + TeleportToDungeon(status->dungeonID, pGroup); } LFGGroupStatus* LFGMgr::GetGroupStatus(ObjectGuid guid) @@ -794,6 +1127,12 @@ LFGGroupStatus* LFGMgr::GetGroupStatus(ObjectGuid guid) } } +/// Legacy per-player decline teardown. +/// +/// No longer on the decline path: ProposalUpdate routes declines through CancelProposal, +/// which implements the three outcomes the client actually describes (decliner out, +/// their premade out, everyone else requeued). Kept because the boot/kick flow still +/// references this shape, but it must not be called for a proposal response. void LFGMgr::ProposalDeclined(ObjectGuid guid, LFGProposal* proposal) { Player* pPlayer = sObjectAccessor.FindPlayer(guid); @@ -843,17 +1182,10 @@ void LFGMgr::ProposalDeclined(ObjectGuid guid, LFGProposal* proposal) } } - if (!leaveGroupLFG) - { - proposal->currentRoles.erase(guid); - proposal->answers.erase(guid); - proposal->groups.erase(guid); - } - else - { - m_proposalMap.erase(proposal->id); - } - + // The proposal is erased by ProposalUpdate, which owns it -- erasing here destroyed + // the object our caller still holds a pointer to. Nor is there any point pruning the + // decliner out of currentRoles/answers/groups any more: the whole proposal is torn + // down either way, and pruning was exactly what let the survivors read as unanimous. LeaveLFG(pPlayer, leaveGroupLFG); } @@ -973,6 +1305,15 @@ void LFGMgr::HandleBossKilled(Player* pPlayer) // get rewards uint32 groupPlrLevel = pGroupPlr->getLevel(); const DungeonFinderRewards* rewards = sObjectMgr.GetDungeonFinderRewards(groupPlrLevel); // Fetch base xp/money reward + if (!rewards) + { + // Unconditionally dereferenced below. dungeonfinder_rewards ships 66 + // rows covering levels 15-80, so every level 81-90 character -- i.e. + // every MoP-relevant one -- crashed the world server on a tracked boss + // kill. No row means no base reward, not a crash. + continue; + } + ItemRewards itemRewards = GetDungeonItemRewards(status->dungeonID, type); // fetch item reward int32 multiplier; // base reward modifier @@ -1002,9 +1343,19 @@ void LFGMgr::HandleBossKilled(Player* pPlayer) } } - // now we can remove the group from our maps - m_groupStatusMap.erase(groupGuid); - m_groupSet.erase(groupGuid); + // The status deliberately SURVIVES the final boss. + // + // It used to be erased here, while the Group object kept GROUPTYPE_LFD. From that + // moment every Group::SendUpdate emitted an LFG block whose dungeon slot resolved + // through GetGroupDungeonEntry -> GetGroupStatus -> null -> 0, i.e. isLfg = 1 with a + // ZERO slot A. That is the case Group.cpp warns is worse than sending no block at + // all: the client copies it, party+232 becomes 0, and IsPartyLFG() goes false -- + // so the Leave Dungeon button and the minimap dungeon state vanished from the first + // tracked boss kill onward, mid-run. + // + // The state was already moved to LFG_STATE_FINISHED_DUNGEON above, which is what + // makes the block's state byte report 2 (IsLFGComplete). Release happens in + // ReleaseGroupLfgStatus, called when the group is actually disbanded. } void LFGMgr::AttemptToKickPlayer(Group* pGroup, ObjectGuid guid, ObjectGuid kicker, std::string reason) @@ -1167,19 +1518,24 @@ void LFGMgr::SendLfgUpdate(ObjectGuid plrGuid, LFGPlayerStatus status, bool isGr } } -void LFGMgr::SendLfgJoinResult(ObjectGuid plrGuid, LfgJoinResult result, LFGState state, partyForbidden const& lockedDungeons) +void LFGMgr::SendLfgJoinResult(ObjectGuid plrGuid, LfgJoinResult result, uint8 detail, partyForbidden const& lockedDungeons) { Player* pPlayer = sObjectAccessor.FindPlayer(plrGuid); if (pPlayer) { - pPlayer->GetSession()->SendLfgJoinResult(result, state, lockedDungeons); + pPlayer->GetSession()->SendLfgJoinResult(result, detail, lockedDungeons); } } void LFGMgr::RemoveOldRoleChecks() { - for (roleCheckMap::iterator roleItr = m_roleCheckMap.begin(); roleItr != m_roleCheckMap.end(); ++roleItr) + // Erase-safe iteration. m_roleCheckMap is an unordered_map, so erasing by + // key destroys the node roleItr points at and the following ++roleItr walks + // freed memory. This is the FIRST thing LFGMgr::Update calls, so it would + // crash or spin the world thread on the first tick that finds an expired + // check. + for (roleCheckMap::iterator roleItr = m_roleCheckMap.begin(); roleItr != m_roleCheckMap.end(); ) { ObjectGuid groupGuid = roleItr->first; @@ -1198,7 +1554,16 @@ void LFGMgr::RemoveOldRoleChecks() SendLfgUpdate(plrGuid, GetPlayerStatus(plrGuid), true); // not in lfg system anymore } - m_roleCheckMap.erase(groupGuid); + // Advance BEFORE erasing, and drop the queue data this check owned: + // the entries JoinLFG wrote for the group would otherwise survive + // with nothing left to resolve them. + m_playerData.erase(groupGuid); + m_queueSet.erase(groupGuid); + roleItr = m_roleCheckMap.erase(roleItr); + } + else + { + ++roleItr; } } } diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 8c1f653ed..28a51ddb2 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -28,6 +28,8 @@ #include "DBCStructure.h" #include "GameEventMgr.h" #include "Group.h" +#include + #include "LFGMgr.h" #include "Object.h" #include "Player.h" @@ -52,7 +54,35 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen // further down, so it must not start indeterminate. uint32 randomDungeonID = 0; // used later if random dungeon has been chosen - LFGPlayers* currentInfo = GetPlayerOrPartyData(guid); + // Refuse a fresh queue while a proposal for this player is still open. + // + // The duplicate cleanup below is guarded on currentInfo, and TryFormGroup erases + // m_playerData the moment a proposal is sent -- so a player sitting on an open + // proposal window has no queue data, skipped that cleanup entirely, and got a + // SECOND live entry. If the first proposal then completed, CreateDungeonGroup put + // them in a dungeon group while they were still queued for another. + // Gated on a proposal that ACTUALLY EXISTS, not on the status flag alone. + // + // The flag is written in several places and cleared in fewer, so trusting it meant + // any path that failed to reset it locked the player out of the dungeon finder until + // relog -- which is exactly what happened when the success path forgot to tear the + // queue entry down. Asking m_proposalMap directly cannot go stale: if there is no + // live proposal listing this player, there is nothing to protect. + if (HasLiveProposalFor(plr->GetObjectGuid())) + { + partyForbidden noneForbidden; + plr->GetSession()->SendLfgJoinResult(ERR_LFG_NO_LFG_OBJECT, LFG_JOIN_DETAIL_NONE, noneForbidden); + return; + } + + // Keyed on whichever entry LISTS this player, not on their own guid. + // + // A solo queuer already absorbed into somebody else's entry has no m_playerData + // under their own guid, so this lookup missed, the duplicate cleanup below was + // skipped, and the solo branch built a SECOND live entry while the merged one still + // listed them -- two queue entries for one player, and potentially two proposals. + ObjectGuid const existingEntryGuid = pGroup ? guid : FindQueueEntryContaining(guid); + LFGPlayers* currentInfo = existingEntryGuid ? GetPlayerOrPartyData(existingEntryGuid) : nullptr; // check if we actually have info on the player/group right now if (currentInfo) @@ -62,17 +92,15 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen // are they already queued? if (currentInfo->currentState == LFG_STATE_QUEUED) { - // remove from that queue so they can later join this one - queueSet::iterator qItr = m_queueSet.find(guid); - if (qItr != m_queueSet.end()) - { - m_queueSet.erase(qItr); - } - // note: do we need to send a packet telling them the current queue is over? + // Take them out of whatever they are in now so they can join this instead. + // RemovePlayerFromQueue rather than a bare m_queueSet.erase, because the + // entry may be shared with other players who must stay queued. + RemovePlayerFromQueue(guid); + currentInfo = nullptr; } // are they already in a dungeon? - if (groupCurrentlyInDungeon) + if (currentInfo && groupCurrentlyInDungeon) { std::set currentDungeon = currentInfo->dungeonList; @@ -275,20 +303,41 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen } } + // Diagnostic: what survived the eligibility filter, and what was removed. + { + std::ostringstream kept; + for (std::set::const_iterator it = dungeons.begin(); it != dungeons.end(); ++it) + { + kept << (it == dungeons.begin() ? "" : ",") << *it; + } + + partyForbidden::const_iterator lockedFor = partyLockedDungeons.find(guid); + + DEBUG_LOG("LFG JoinLFG: %s isRandom=%u randomId=%u kept={%s} lockedCount=%u", + plr->GetName(), uint32(isRandom), randomDungeonID, kept.str().c_str(), + uint32(lockedFor != partyLockedDungeons.end() ? lockedFor->second.size() : 0)); + } + if (!dungeons.empty()) { partyLockedDungeons.clear(); } else { - result = (pGroup) ? ERR_LFG_NO_SLOTS_PARTY : ERR_LFG_NO_SLOTS_PLAYER; + // NO_SLOTS_PLAYER for a party too. 18414 has no NO_SLOTS_PARTY code: the + // GlobalString survives at index 0x2EE but nothing in the client's result + // table maps to it, so the old party value (0x06) matched no entry and the + // client displayed nothing at all -- the most common way to queue and see + // the button do nothing. 0x20 is also the code that carries the per-player + // lock array, so the party is told WHICH dungeons were locked. + result = ERR_LFG_NO_SLOTS_PLAYER; } } // If our result is not ERR_LFG_OK, send join result now with err message if (result != ERR_LFG_OK) { - plr->GetSession()->SendLfgJoinResult(result, LFG_STATE_NONE, partyLockedDungeons); + plr->GetSession()->SendLfgJoinResult(result, LFG_JOIN_DETAIL_NONE, partyLockedDungeons); return; } @@ -303,11 +352,16 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen roleCheck.leaderGuidRaw = leaderGuid.GetRawValue(); roleCheck.waitForRoleTime = time_t(time(NULL) + LFG_TIME_ROLECHECK); - m_roleCheckMap[guid] = roleCheck; - // place original dungeon ID back in the set + // + // The expansion is SAVED first. dungeonList must go back to the single category row -- + // that is what the client is shown and what the reward lookup keys on -- but a category + // row is not a place: all 12 TypeID 6 rows carry MapID 0 or 0xFFFFFFFF. Discarding the + // expansion here is what left a random queue proposing a row it could not teleport to. + std::set candidates; if (isRandom) { + candidates = dungeons; dungeons.clear(); dungeons.insert(randomDungeonID); } @@ -316,10 +370,18 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen { if (Player* pGroupPlr = itr->getSource()) { - LFGPlayerStatus overallStatus(LFG_STATE_NONE, LFG_UPDATE_JOIN, dungeons, comments); + // ROLECHECK, not NONE -- same reason as the solo path below. The update + // announced the join while reporting the state as NONE, and only moved to + // ROLECHECK for the stored copy afterwards. + // Reason 24, not 6 -- same correction the solo path already carries. + // Reason 6 is retail's re-queue-from-inside-a-dungeon reason (257 of 276 + // observed joins open with 24 and none with 6), and BOTH 6 and 13 make the + // client display ERR_LFG_JOINED_QUEUE. Opening with 6 and then having + // PerformRoleCheck send 13 announced "You are now queued in the Dungeon + // Finder" TWICE -- once in chat and once centre-screen. Observed live. + LFGPlayerStatus overallStatus(LFG_STATE_ROLECHECK, LFG_UPDATE_JOIN_QUEUE_INITIAL, dungeons, comments); pGroupPlr->GetSession()->SendLfgUpdate(true, overallStatus); - overallStatus.state = LFG_STATE_ROLECHECK; ObjectGuid plrGuid = pGroupPlr->GetObjectGuid(); roleCheck.currentRoles[plrGuid] = 0; @@ -327,17 +389,28 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen m_playerStatusMap[plrGuid] = overallStatus; } } + + // Stored AFTER the loop above, not before it. The stored copy used to be taken + // while currentRoles was still empty, so the role check the rest of the system + // saw listed nobody: PerformRoleCheck then found "everyone" had answered as soon + // as the FIRST member replied, and a five-man queued on a one-entry role map. + m_roleCheckMap[guid] = roleCheck; + // used later if they enter the queue LFGPlayers groupInfo(LFG_STATE_NONE, dungeons, roleCheck.currentRoles, comments, false, time(NULL), 0, 0, 0); + groupInfo.candidateDungeons = candidates; + groupInfo.ticketId = AllocateTicketId(); m_playerData[guid] = groupInfo; PerformRoleCheck(plr, pGroup, (uint8)roles); } else { - // place original dungeon ID back in the set + // place original dungeon ID back in the set -- expansion saved first, as above + std::set candidates; if (isRandom) { + candidates = dungeons; dungeons.clear(); dungeons.insert(randomDungeonID); } @@ -346,21 +419,58 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen roleMap playerRole; playerRole[guid] = (uint8)roles; + { + std::ostringstream stored; + for (std::set::const_iterator it = dungeons.begin(); it != dungeons.end(); ++it) + { + stored << (it == dungeons.begin() ? "" : ",") << *it; + } + DEBUG_LOG("LFG JoinLFG: solo entry for %s stores dungeons={%s}", + plr->GetName(), stored.str().c_str()); + } + LFGPlayers playerInfo(LFG_STATE_QUEUED, dungeons, playerRole, comments, false, time(NULL), 0, 0, 0); + playerInfo.candidateDungeons = candidates; + playerInfo.ticketId = AllocateTicketId(); m_playerData[guid] = playerInfo; // set up a status struct for client requests/updates + // + // QUEUED, not NONE. This used to announce the join while reporting the player's LFG + // state as LFG_STATE_NONE, and only correct it to QUEUED afterwards for storage -- so + // the packet said "you have joined the dungeon finder" and "you are not in the dungeon + // finder" at the same time, and the client had no active queue to announce. Observed + // live: pressing Queue produced no notification at all, and the only one the player + // ever saw was a stale status replayed after they had already entered the dungeon. LFGPlayerStatus plrStatus; - plrStatus.updateType = LFG_UPDATE_JOIN; - plrStatus.state = LFG_STATE_NONE; + plrStatus.updateType = LFG_UPDATE_JOIN_QUEUE_INITIAL; + plrStatus.state = LFG_STATE_QUEUED; plrStatus.dungeonList = dungeons; plrStatus.comment = comments; - // Send information back to the client - plr->GetSession()->SendLfgJoinResult(result, LFG_STATE_NONE, partyLockedDungeons); + // Retail's join burst, in this order (capture-000720 seq 182-185, and the same + // shape at capture-000044 seq 3601-3605): + // + // 1. SMSG_LFG_UPDATE_STATUS reason 24, queued = 0 + // 2. SMSG_LFG_UPDATE_STATUS reason 13, queued = 1 + // 3. SMSG_LFG_JOIN_RESULT + // 4. SMSG_LFG_UPDATE_STATUS reason 13 again -- a byte-identical duplicate of 2 + // + // We used to lead with the join result and send a single status packet. The + // opening reason was 6, which retail uses for re-queueing from INSIDE a dungeon + // and never to open a fresh queue: 257 of 276 observed joins lead with 24. + // + // Step 4 is not a mistake in the capture. Retail repeats reason 13 either side + // of the join result in every session walked. + plr->GetSession()->SendLfgUpdate(false, plrStatus); + + plrStatus.updateType = LFG_UPDATE_ADDED_TO_QUEUE; + plr->GetSession()->SendLfgUpdate(false, plrStatus); + + plr->GetSession()->SendLfgJoinResult(result, LFG_JOIN_DETAIL_NONE, partyLockedDungeons); + plr->GetSession()->SendLfgUpdate(false, plrStatus); - plrStatus.state = LFG_STATE_QUEUED; m_playerStatusMap[guid] = plrStatus; AddToQueue(guid); } @@ -380,22 +490,50 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) ObjectGuid grpPlrGuid = pGroupPlr->GetObjectGuid(); LFGPlayerStatus grpPlrStatus = GetPlayerStatus(grpPlrGuid); - switch (grpPlrStatus.state) + + if (grpPlrStatus.state == LFG_STATE_ROLECHECK) + { + // A role check in progress is aborted rather than answered with a + // leave; PerformRoleCheck tells everyone and tears the check down. + PerformRoleCheck(NULL, pGroup, 0); + } + else { - case LFG_STATE_PROPOSAL: - case LFG_STATE_QUEUED: - grpPlrStatus.updateType = LFG_UPDATE_LEAVE; - grpPlrStatus.state = LFG_STATE_NONE; + // ALWAYS answer, whatever state is recorded. + // + // This was a switch over four states with no default, so any other + // state -- above all LFG_STATE_NONE, which is what a player holds + // after declining a proposal -- fell through and sent the client + // NOTHING. Observed live in an isolated repro: press I, accept the + // backfill, decline the proposal, then click Leave Queue fourteen + // times and receive not one packet in reply, because a player who is + // in an LFG party takes this branch rather than the solo one. + // + // The solo path already always answers. The two must not disagree: + // which branch runs depends only on whether the player happens to be + // grouped, which is not something the client can reason about when it + // asks to leave. + // + // Retail's pair is reason 14 then reason 8, but 14 carries joined = 1 + // and the client files a status body by category, derived from the + // dungeon list -- so with an empty list it is only safe to send the + // terminal. 0 of 5291 retail bodies carry an empty dungeon list. + if (!grpPlrStatus.dungeonList.empty()) + { + grpPlrStatus.updateType = LFG_UPDATE_PROPOSAL_BEGIN; SendLfgUpdate(grpPlrGuid, grpPlrStatus, true); - break; - case LFG_STATE_ROLECHECK: - PerformRoleCheck(NULL, pGroup, 0); - break; - //todo: other state cases after they get implemented + } + + grpPlrStatus.updateType = LFG_UPDATE_LEAVE; + grpPlrStatus.state = LFG_STATE_NONE; + SendLfgUpdate(grpPlrGuid, grpPlrStatus, true); + + SetPlayerState(grpPlrGuid, LFG_STATE_NONE); } - m_playerData.erase(grpPlrGuid); - m_playerStatusMap.erase(grpPlrGuid); + // Same hazard as the solo path: a party member may be listed in an + // entry keyed by something other than their own guid. + RemovePlayerFromQueue(grpPlrGuid); } } @@ -406,21 +544,72 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) { ObjectGuid plrGuid = plr->GetObjectGuid(); + // Snapshot BEFORE the teardown. CancelProposal erases m_playerStatusMap for the + // players it blames, so reading the status afterwards handed back a default- + // constructed record and the reply went out with an empty dungeon list -- a shape + // that occurs 0 times in 5291 retail bodies. LFGPlayerStatus plrStatus = GetPlayerStatus(plrGuid); - switch (plrStatus.state) + + // Tear down a proposal the player never answered, so the queue entry it was built + // from is released and cannot re-propose on the next tick. Note this may itself + // send the player an LFG_UPDATE_LEAVE. + bool const hadLiveProposal = HasLiveProposalFor(plrGuid); + CancelProposalsFor(plrGuid); + if (hadLiveProposal) { - case LFG_STATE_PROPOSAL: - case LFG_STATE_QUEUED: - plrStatus.updateType = LFG_UPDATE_LEAVE; - plrStatus.state = LFG_STATE_NONE; - SendLfgUpdate(plrGuid, plrStatus, false); - break; - // do other states after being implemented, if applicable for a single plr + // CancelProposal already answered with a properly populated LEAVE. Sending a + // second one here only adds a duplicate, so stop. + RemovePlayerFromQueue(plrGuid); + return; } - m_queueSet.erase(plrGuid); - m_playerData.erase(plrGuid); - m_playerStatusMap.erase(plrGuid); + // ALWAYS answer, whatever state we have recorded. + // + // This used to switch on the recorded state and simply fall through when it + // matched nothing, sending the client no packet at all -- and a CMSG_LFG_LEAVE + // that draws no reply leaves the dungeon finder showing a queue the player + // cannot dismiss. Observed live: four leave requests in a row, zero packets sent, + // stuck until relog. + // + // The state need not be one this switch ever knew about. TryFormGroup erases the + // queue entry the moment a proposal goes out, so a player who ignores the popup + // holds a status that matched none of the old cases; GetPlayerStatus then hands + // back a default-constructed LFG_STATE_NONE for anyone with no record at all. + // Neither is a reason to say nothing -- the client asked to leave, so tell it that + // it has, and reconcile the server side underneath. + // + // Retail's pair is reason 14 then reason 8 -- but ONLY when we can name the + // dungeons. Reason 14 carries joined = 1, and the client files a status body by + // category, which it derives from the dungeon list. With an empty list it cannot + // attribute either packet to a category, so the joined = 1 can stick where the + // clearing packet does not reach: GetLFGMode then answers "suspended", which is + // non-nil, and QueueStatusFrame_Update lights the minimap eye for any non-nil + // mode. Observed live -- every click played the leave sound and left the eye on. + // + // 0 of 5291 retail status bodies carry an empty dungeon list, so the empty form + // is outside anything the client is built to handle. When we have no record, + // send the terminal reason 8 alone: it still answers the request and still fires + // the notification, without first asserting a joined state we cannot then clear. + if (!plrStatus.dungeonList.empty()) + { + plrStatus.updateType = LFG_UPDATE_PROPOSAL_BEGIN; + SendLfgUpdate(plrGuid, plrStatus, false); + } + + plrStatus.updateType = LFG_UPDATE_LEAVE; + plrStatus.state = LFG_STATE_NONE; + SendLfgUpdate(plrGuid, plrStatus, false); + + SetPlayerState(plrGuid, LFG_STATE_NONE); + + // NOT `m_playerData.erase(plrGuid)`. + // + // A solo queuer who has already been merged into somebody else's entry has no + // data under their own guid, so erasing by it did nothing at all: the client was + // told LFG_UPDATE_LEAVE and cleared its UI while the server kept them queued + // inside the merged entry -- and would have pulled them into a later proposal + // for a dungeon they had left. + RemovePlayerFromQueue(plrGuid); } } @@ -453,7 +642,9 @@ LFGProposal* LFGMgr::GetProposalData(uint32 proposalID) LfgJoinResult LFGMgr::GetJoinResult(Player* plr) { - LfgJoinResult result; + // Initialised. `LfgJoinResult result;` was read uninitialised when a group had + // members but every getSource() returned null. + LfgJoinResult result = ERR_LFG_OK; Group* pGroup = plr->GetGroup(); /* Reasons for not entering: @@ -480,6 +671,21 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) { result = ERR_LFG_RANDOM_COOLDOWN_PLAYER; } + else if (plr->getLevel() < 15) + { + // The level test previously lived only in the group branch, so a solo player + // below 15 was never checked at all. + result = ERR_LFG_CANT_USE_DUNGEONS; + } + + // Whatever the caller's own verdict is, it stands. The solo branch below used to + // end in an unconditional `result = ERR_LFG_OK`, throwing away every check above + // it: a solo player with Dungeon Deserter, on LFG cooldown, in a battleground, in + // an arena or below level 15 was always admitted. + if (result != ERR_LFG_OK) + { + return result; + } if (pGroup) { @@ -511,10 +717,10 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) { result = ERR_LFG_RANDOM_COOLDOWN_PARTY; } - else - { - result = ERR_LFG_OK; - } + // No `else { result = ERR_LFG_OK; }` here. Assigning per member meant + // only the LAST iterated member's verdict survived, so a party + // containing one deserter was admitted whenever the last member + // happened to be clean. ++currentMemberCount; } @@ -526,10 +732,6 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) } } } - else - { - result = ERR_LFG_OK; - } return result; } @@ -550,6 +752,23 @@ LFGPlayerStatus LFGMgr::GetPlayerStatus(ObjectGuid guid) bool LFGMgr::GetStatusPacketData(ObjectGuid queueGuid, ObjectGuid playerGuid, LFGStatusPacketData& data) const { playerData::const_iterator queue = m_playerData.find(queueGuid); + + // A merged solo queuer has no entry of their own: MergeGroups folds them into the + // absorbing entry and erases theirs. The direct lookup then missed, the caller was + // handed a default-constructed struct, and their SMSG_LFG_UPDATE_STATUS went out + // with zero roles, zero needed counts and a zero join time -- which is most of the + // dungeon finder UI blank while the absorbing player's looked perfectly normal. + // + // So fall back to whichever entry actually LISTS this player. + if (queue == m_playerData.end()) + { + ObjectGuid const containing = FindQueueEntryContaining(playerGuid); + if (containing) + { + queue = m_playerData.find(containing); + } + } + if (queue == m_playerData.end()) return false; @@ -559,6 +778,7 @@ bool LFGMgr::GetStatusPacketData(ObjectGuid queueGuid, ObjectGuid playerGuid, LF data.roles = role->second; data.joinedTime = uint32(information.joinedTime); + data.ticketId = information.ticketId; data.neededTanks = information.neededTanks; data.neededHealers = information.neededHealers; data.neededDps = information.neededDps; diff --git a/src/game/WorldHandlers/World.cpp b/src/game/WorldHandlers/World.cpp index b83d7ff1f..413162971 100644 --- a/src/game/WorldHandlers/World.cpp +++ b/src/game/WorldHandlers/World.cpp @@ -750,7 +750,16 @@ void World::SetInitialWorldSettings() m_timers[WUPDATE_AHBOT].SetInterval(20 * IN_MILLISECONDS); // every 20 sec // for Dungeon Finder - m_timers[WUPDATE_LFGMGR].SetInterval(30 * IN_MILLISECONDS); // every 30 sec + // 5 seconds, not 30. SMSG_LFG_QUEUE_STATUS is the only periodic LFG packet retail + // sends and its period is a hard 5000 ms: across 1283 one-beat intervals in + // millisecond-resolution captures the mean is 4999.7 ms, and long-run drift settles + // it (capture-000873: 1258 beats over 6,289,554 ms = 4999.98 ms/beat). The 10/15/20 s + // gaps that appear are exact multiples -- dropped sniff frames -- and the payload's + // own queuedTime still advances by exactly 5 across them, so the server never missed + // a beat. At 30 s the queue UI updated six times slower than the client expects, and + // matchmaking, role-check expiry and proposal expiry were all equally coarse because + // this one timer gates them together. + m_timers[WUPDATE_LFGMGR].SetInterval(5 * IN_MILLISECONDS); // for AutoBroadcast sLog.outString("Starting AutoBroadcast System"); @@ -1170,6 +1179,21 @@ void World::Update(uint32 diff) Player::DeleteOldCharacters(); } + ///- Match queued dungeon finder entries and expire stale role checks. + // + // The WUPDATE_LFGMGR timer was configured at startup but never consumed, so + // LFGMgr::Update had no caller anywhere in the tree: players could join the queue + // and nothing ever looked at it again. Ticking it is deliberately the LAST step of + // the dungeon finder work, not the first, because everything it reaches had to be + // correct before it ran -- the reaper it calls first erased while iterating, the + // matchmaker it calls next could not form a group under any input, and the proposal + // it can now send read two uninitialised members to choose a branch. + if (m_timers[WUPDATE_LFGMGR].Passed()) + { + m_timers[WUPDATE_LFGMGR].Reset(); + sLFGMgr.Update(); + } + // execute callbacks from sql queries that were queued recently UpdateResultQueue();