diff --git a/dCommon/dEnums/MessageType/Chat.h b/dCommon/dEnums/MessageType/Chat.h index 67dad5805..83054399f 100644 --- a/dCommon/dEnums/MessageType/Chat.h +++ b/dCommon/dEnums/MessageType/Chat.h @@ -76,5 +76,6 @@ namespace MessageType { CREATE_TEAM, GUILD_SET_RANK, // world -> chat: /gpromote, /gdemote, /gleader GUILD_DISBAND, // world -> chat: /gdisband + MATCH_TRANSFER, // chat -> world: a lobby's match has an instance; send these players there }; } diff --git a/dNet/ChatPackets.cpp b/dNet/ChatPackets.cpp index d023e0261..64fa0618e 100644 --- a/dNet/ChatPackets.cpp +++ b/dNet/ChatPackets.cpp @@ -648,3 +648,63 @@ bool ChatPackets::GuildStatus::Deserialize(RakNet::BitStream& bitStream) { VALIDATE_READ(bitStream.Read(guildName)); return true; } + +void ChatPackets::MatchRequest::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(playerID); + bitStream.Write(type); + bitStream.Write(value); + bitStream.Write(activityID); + BitStreamUtils::WriteLengthPrefixed(bitStream, playerName); + BitStreamUtils::WriteLengthPrefixed(bitStream, playerChoices); + bitStream.Write(instanceMapID); + bitStream.Write(minTeams); + bitStream.Write(maxTeams); + bitStream.Write(minTeamSize); + bitStream.Write(maxTeamSize); + bitStream.Write(waitTime); + bitStream.Write(startDelay); +} + +bool ChatPackets::MatchRequest::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(playerID)); + VALIDATE_READ(bitStream.Read(type)); + if (type != eMatchRequestType::JOIN && type != eMatchRequestType::READY && type != eMatchRequestType::LEAVE) return false; + VALIDATE_READ(bitStream.Read(value)); + VALIDATE_READ(bitStream.Read(activityID)); + VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed(bitStream, playerName, LoginSessionNotify::MAX_NAME_LENGTH)); + VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed(bitStream, playerChoices, 4096)); + VALIDATE_READ(bitStream.Read(instanceMapID)); + VALIDATE_READ(bitStream.Read(minTeams)); + VALIDATE_READ(bitStream.Read(maxTeams)); + VALIDATE_READ(bitStream.Read(minTeamSize)); + VALIDATE_READ(bitStream.Read(maxTeamSize)); + VALIDATE_READ(bitStream.Read(waitTime)); + VALIDATE_READ(bitStream.Read(startDelay)); + return true; +} + +void ChatPackets::MatchTransfer::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(activityID); + WriteZone(bitStream, zoneID); + BitStreamUtils::WriteLengthPrefixed(bitStream, serverIP); + bitStream.Write(serverPort); + bitStream.Write(mythranShift); + bitStream.Write(players.size()); + for (const auto player : players) bitStream.Write(player); +} + +bool ChatPackets::MatchTransfer::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(activityID)); + VALIDATE_READ(ReadZone(bitStream, zoneID)); + VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed(bitStream, serverIP, 255)); + VALIDATE_READ(bitStream.Read(serverPort)); + uint8_t shift{}; + VALIDATE_READ(bitStream.Read(shift)); + mythranShift = shift != 0; + uint32_t count{}; + VALIDATE_READ(bitStream.Read(count)); + if (count > MAX_PLAYERS) return false; + players.resize(count); + for (auto& player : players) VALIDATE_READ(bitStream.Read(player)); + return true; +} diff --git a/dNet/ChatPackets.h b/dNet/ChatPackets.h index a7fbedb1e..6310f4463 100644 --- a/dNet/ChatPackets.h +++ b/dNet/ChatPackets.h @@ -473,6 +473,55 @@ namespace ChatPackets { bool Deserialize(RakNet::BitStream& bitStream) override; }; + /** + * Activity matchmaking (docs/Matchmaking.md). The chat server keeps the lobbies of every world, so players on + * different instances of a zone are matched into the same activity instance. + */ + enum class eMatchRequestType : int32_t { + JOIN = 0, // the client's MatchRequest type 0 + READY = 1, // the client's MatchRequest type 1 (value: ready or not) + LEAVE = 2, // DLU: the player left the lobby (LobbyExit) or can't stay in it + }; + + // World -> chat: a player joins, readies in or leaves an activity lobby. On a join the world sends what it read + // from the activity (CDClient Activities, with its own overrides such as solo racing), so chat needs no CDClient. + struct MatchRequest : public LUBitStream { + LWOOBJID playerID{}; + eMatchRequestType type{}; + int32_t value{}; // READY: 1 ready, 0 not ready + int32_t activityID{}; + std::string playerName; // u32 length, then 1 byte per character + std::string playerChoices; // the client's name-value text (like "droppedItem=13:"), u32 length then bytes + uint32_t instanceMapID{}; + int32_t minTeams{}; + int32_t maxTeams{}; + int32_t minTeamSize{}; + int32_t maxTeamSize{}; + int32_t waitTime{}; // milliseconds + int32_t startDelay{}; // milliseconds + + MatchRequest() : LUBitStream(ServiceType::CHAT, MessageType::Chat::MATCH_REQUEST) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // Chat -> world (DLU): a lobby's match has its instance; send these players (the ones in this world) there + struct MatchTransfer : public LUBitStream { + // More players than this in one packet is a bad packet + static constexpr uint32_t MAX_PLAYERS = 64; + + int32_t activityID{}; + LWOZONEID zoneID{}; // map, instance and clone of the activity instance + std::string serverIP; // u32 length, then 1 byte per character + uint16_t serverPort{}; + bool mythranShift{}; + std::vector players; // u32 count + + MatchTransfer() : LUBitStream(ServiceType::CHAT, MessageType::Chat::MATCH_TRANSFER) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + /** * Chat-service packets the client receives, from the chat server (routed through the player's world) or from its world. */ diff --git a/dNet/ClientPackets.cpp b/dNet/ClientPackets.cpp index 94339a9be..94a87e7e8 100644 --- a/dNet/ClientPackets.cpp +++ b/dNet/ClientPackets.cpp @@ -912,6 +912,21 @@ namespace ClientPackets { VALIDATE_READ(ReadZone(bitStream, zoneID)); return true; } + + void MatchUpdate::Serialize(RakNet::BitStream& bitStream) const { + TeamGameMsg::Serialize(bitStream); + BitStreamUtils::WriteNameValueText(bitStream, std::u16string(data.begin(), data.end())); + bitStream.Write(type); + } + + bool MatchUpdate::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(TeamGameMsg::Deserialize(bitStream)); + std::u16string wide; + VALIDATE_READ(BitStreamUtils::ReadNameValueText(bitStream, wide)); + data.assign(wide.begin(), wide.end()); + VALIDATE_READ(bitStream.Read(type)); + return true; + } } namespace ClientPackets { diff --git a/dNet/ClientPackets.h b/dNet/ClientPackets.h index 42ae588e6..cfb375594 100644 --- a/dNet/ClientPackets.h +++ b/dNet/ClientPackets.h @@ -22,6 +22,7 @@ #include "eGuildCreateResponse.h" #include "eGuildInviteResponse.h" #include "eGuildRank.h" +#include "eMatchUpdate.h" #include "eUgcResourceType.h" #include "MessageType/Client.h" #include "MessageType/Game.h" @@ -553,6 +554,17 @@ namespace ClientPackets { bool Deserialize(RakNet::BitStream& bitStream) override; }; + // The activity lobby's MatchUpdate, written by the chat server's matchmaking (docs/Matchmaking.md). Byte for byte + // GameMessages::MatchUpdate: name-value text (widened to UTF-16), then the eMatchUpdate type. + struct MatchUpdate : public TeamGameMsg { + std::string data; // name-value text, one byte per character here + eMatchUpdate type{}; + + MatchUpdate() : TeamGameMsg(MessageType::Game::MATCH_UPDATE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + /** * Guild packets (docs/Guilds.md), written by the chat server and routed through the player's world. The client * copies them into packed structs: every string is a fixed-size UTF-16 buffer it reads up to the first NUL, so diff --git a/tests/dCommonTests/MessageIdPinTests.cpp b/tests/dCommonTests/MessageIdPinTests.cpp index 4dfd52497..0a041e65d 100644 --- a/tests/dCommonTests/MessageIdPinTests.cpp +++ b/tests/dCommonTests/MessageIdPinTests.cpp @@ -22,7 +22,7 @@ static_assert(static_cast(MessageType::Auth::LEGOINTERFACE_AUTH_RESPONS static_assert(static_cast(MessageType::Auth::SESSIONKEY_RECEIVED_CONFIRM) == 4); static_assert(static_cast(MessageType::Auth::RUNTIME_CONFIG) == 5); -// MessageType::Chat: 69 enumerators +// MessageType::Chat: 72 enumerators static_assert(static_cast(MessageType::Chat::LOGIN_SESSION_NOTIFY) == 0); static_assert(static_cast(MessageType::Chat::GENERAL_CHAT_MESSAGE) == 1); static_assert(static_cast(MessageType::Chat::PRIVATE_CHAT_MESSAGE) == 2); @@ -92,6 +92,9 @@ static_assert(static_cast(MessageType::Chat::PRG_CSR_COMMAND) == 65); static_assert(static_cast(MessageType::Chat::HEARTBEAT_REQUEST_FROM_WORLD) == 66); static_assert(static_cast(MessageType::Chat::UPDATE_FREE_TRIAL_STATUS) == 67); static_assert(static_cast(MessageType::Chat::CREATE_TEAM) == 68); +static_assert(static_cast(MessageType::Chat::GUILD_SET_RANK) == 69); +static_assert(static_cast(MessageType::Chat::GUILD_DISBAND) == 70); +static_assert(static_cast(MessageType::Chat::MATCH_TRANSFER) == 71); // MessageType::Client: 67 enumerators static_assert(static_cast(MessageType::Client::LOGIN_RESPONSE) == 0); diff --git a/tests/dGameTests/dNetTests/CMakeLists.txt b/tests/dGameTests/dNetTests/CMakeLists.txt index 2052bf240..0f2c36ef4 100644 --- a/tests/dGameTests/dNetTests/CMakeLists.txt +++ b/tests/dGameTests/dNetTests/CMakeLists.txt @@ -3,6 +3,7 @@ SET(DNET_TESTS "GuildPacketsTests.cpp" "CommonAuthPacketsTests.cpp" "MasterPacketsTests.cpp" + "MatchmakingPacketsTests.cpp" "PacketCaptureTests.cpp" "ServerTrafficTests.cpp" "WorldPacketsTests.cpp") diff --git a/tests/dGameTests/dNetTests/MatchmakingPacketsTests.cpp b/tests/dGameTests/dNetTests/MatchmakingPacketsTests.cpp new file mode 100644 index 000000000..c713f9400 --- /dev/null +++ b/tests/dGameTests/dNetTests/MatchmakingPacketsTests.cpp @@ -0,0 +1,135 @@ +#include "ChatPackets.h" +#include "ClientPackets.h" +#include "ActivityMessages.h" +#include "GameDependencies.h" +#include "PacketTestUtils.h" +#include "dGameMessagesTests/GameMessageTestUtils.h" + +#include + +using namespace PacketTestUtils; +using GameMessageTestUtils::RoundTrip; +using GameMessageTestUtils::ExpectTruncatedFails; + +// Packets of the chat server's activity matchmaking (docs/Matchmaking.md) + +TEST(MatchmakingPacketsTests, MessageIdsAreAppended) { + // MATCH_REQUEST is the chat service's own id; MATCH_TRANSFER is DLU's, after GUILD_DISBAND + EXPECT_EQ(static_cast(MessageType::Chat::MATCH_REQUEST), 52u); + EXPECT_EQ(static_cast(MessageType::Chat::MATCH_TRANSFER), static_cast(MessageType::Chat::GUILD_DISBAND) + 1); + EXPECT_EQ(ChatPackets::MatchRequest().internalPacketID, static_cast(MessageType::Chat::MATCH_REQUEST)); + EXPECT_EQ(ChatPackets::MatchTransfer().internalPacketID, static_cast(MessageType::Chat::MATCH_TRANSFER)); +} + +TEST(MatchmakingPacketsTests, MatchRequestRoundTrip) { + for (const auto type : { ChatPackets::eMatchRequestType::JOIN, ChatPackets::eMatchRequestType::READY, ChatPackets::eMatchRequestType::LEAVE }) { + ChatPackets::MatchRequest request; + request.playerID = 0x1000000012345678LL; + request.type = type; + request.value = 1; + request.activityID = 42; + request.playerName = "GruntMonkey"; + request.playerChoices = "droppedItem=13:1152921510659010409"; + request.instanceMapID = 1203; + request.minTeams = 2; + request.maxTeams = 6; + request.minTeamSize = 1; + request.maxTeamSize = 1; + request.waitTime = 60000; + request.startDelay = 3000; + + const auto copy = RoundTrip(request); + EXPECT_EQ(copy.playerID, request.playerID); + EXPECT_EQ(copy.type, type); + EXPECT_EQ(copy.value, 1); + EXPECT_EQ(copy.activityID, 42); + EXPECT_EQ(copy.playerName, "GruntMonkey"); + EXPECT_EQ(copy.playerChoices, request.playerChoices); + EXPECT_EQ(copy.instanceMapID, 1203u); + EXPECT_EQ(copy.minTeams, 2); + EXPECT_EQ(copy.maxTeams, 6); + EXPECT_EQ(copy.minTeamSize, 1); + EXPECT_EQ(copy.maxTeamSize, 1); + EXPECT_EQ(copy.waitTime, 60000); + EXPECT_EQ(copy.startDelay, 3000); + ExpectTruncatedFails(request); + } +} + +TEST(MatchmakingPacketsTests, MatchRequestRejectsUnknownTypeAndLongName) { + ChatPackets::MatchRequest request; + request.type = static_cast(7); + RakNet::BitStream bitStream; + request.Serialize(bitStream); + ChatPackets::MatchRequest copy; + EXPECT_FALSE(copy.Deserialize(bitStream)); + + request.type = ChatPackets::eMatchRequestType::JOIN; + request.playerName = std::string(ChatPackets::LoginSessionNotify::MAX_NAME_LENGTH + 1, 'x'); + RakNet::BitStream longName; + request.Serialize(longName); + EXPECT_FALSE(copy.Deserialize(longName)); +} + +TEST(MatchmakingPacketsTests, MatchTransferRoundTrip) { + ChatPackets::MatchTransfer transfer; + transfer.activityID = 5; + transfer.zoneID = LWOZONEID(1101, 42, 0xDEADBEEF); + transfer.serverIP = "171.20.35.21"; + transfer.serverPort = 2007; + transfer.mythranShift = false; + transfer.players = { 0x1000000000000001LL, 0x1000000000000002LL }; + + const auto copy = RoundTrip(transfer); + EXPECT_EQ(copy.activityID, 5); + EXPECT_EQ(copy.zoneID.GetMapID(), 1101); + EXPECT_EQ(copy.zoneID.GetInstanceID(), 42); + EXPECT_EQ(copy.zoneID.GetCloneID(), 0xDEADBEEFu); + EXPECT_EQ(copy.serverIP, "171.20.35.21"); + EXPECT_EQ(copy.serverPort, 2007); + EXPECT_FALSE(copy.mythranShift); + EXPECT_EQ(copy.players, transfer.players); + ExpectTruncatedFails(transfer); +} + +TEST(MatchmakingPacketsTests, MatchTransferRejectsTooManyPlayers) { + ChatPackets::MatchTransfer transfer; + transfer.players.assign(ChatPackets::MatchTransfer::MAX_PLAYERS + 1, 1); + RakNet::BitStream bitStream; + transfer.Serialize(bitStream); + ChatPackets::MatchTransfer copy; + EXPECT_FALSE(copy.Deserialize(bitStream)); +} + +// The chat server's MatchUpdate is byte for byte the world's GameMessages::MatchUpdate +TEST(MatchmakingPacketsTests, ClientMatchUpdateMatchesGameMessage) { + const std::pair cases[] = { + { "player=9:1152921510436607007\nplayerName=0:GruntMonkey", eMatchUpdate::PLAYER_ADDED }, + { "droppedItem=9:1152921510659010409\nplayer=9:1152921510436607007\nplayerName=0:GruntMonkey", eMatchUpdate::PLAYER_ADDED }, + { "player=9:1152921510808957955", eMatchUpdate::PLAYER_READY }, + { "time=3:49.704", eMatchUpdate::PHASE_WAIT_READY }, + { "", eMatchUpdate::PLAYER_REMOVED }, + }; + for (const auto& [data, type] : cases) { + GameMessages::MatchUpdate world; + world.target = 0x1000000012345678LL; + world.data = data; + world.type = type; + RakNet::BitStream worldStream; + world.WritePacket(worldStream); + + ClientPackets::MatchUpdate chat; + chat.target = world.target; + chat.data = data; + chat.type = type; + RakNet::BitStream chatStream; + chat.WritePacket(chatStream); + + EXPECT_PACKET_EQ(FromBitStream(worldStream), FromBitStream(chatStream)); + + const auto copy = RoundTrip(chat); + EXPECT_EQ(copy.target, chat.target); + EXPECT_EQ(copy.data, data); + EXPECT_EQ(copy.type, type); + } +}