diff --git a/dChatServer/Matchmaking.cpp b/dChatServer/Matchmaking.cpp new file mode 100644 index 000000000..31a6c7d60 --- /dev/null +++ b/dChatServer/Matchmaking.cpp @@ -0,0 +1,165 @@ +#include "Matchmaking.h" + +#include +#include + +#include "GeneralUtils.h" +#include "LDFFormat.h" + +namespace Matchmaking { + uint32_t ActivitySettings::Capacity() const { + return static_cast(std::max(maxTeamSize == 1 ? maxTeams : maxTeamSize, 1)); + } + + uint32_t ActivitySettings::Minimum() const { + return static_cast(std::max(maxTeamSize == 1 ? minTeams : minTeamSize, 1)); + } + + std::string PlayerText(const LWOOBJID player) { + return LDFData(u"player", player).GetString(); + } + + namespace { + // The racing car the client names in its MatchRequest ("droppedItem=13:", its text); live showed it to the + // lobby as an object ID ("droppedItem=9:") before the player's line + std::string DroppedItemText(const std::string& choices) { + for (const auto& line : GeneralUtils::SplitString(choices, '\n')) { + const auto equals = line.find('='); + if (equals == std::string::npos || line.substr(0, equals) != "droppedItem") continue; + const auto colon = line.find(':', equals); + if (colon == std::string::npos) continue; + const auto item = GeneralUtils::TryParse(line.substr(colon + 1)); + if (!item) continue; + return LDFData(u"droppedItem", *item).GetString() + "\n"; + } + return ""; + } + } + + // The name as a wide string (type 0), as live sent it + std::string PlayerAddedText(const LobbyPlayer& player) { + return DroppedItemText(player.choices) + PlayerText(player.id) + "\n" + LDFData(u"playerName", GeneralUtils::UTF8ToUTF16(player.name)).GetString(); + } + + std::string TimeText(const float seconds) { + return LDFData(u"time", seconds).GetString(); + } + + void Lobbies::Broadcast(const Lobby& lobby, const eMatchUpdate type, const std::string& data, std::vector& out) { + for (const auto& player : lobby.players) out.push_back({ player.id, type, data }); + } + + const Lobby* Lobbies::Find(const LWOOBJID player) const { + for (const auto& lobby : m_Lobbies | std::views::values) { + for (const auto& lobbyPlayer : lobby.players) { + if (lobbyPlayer.id == player) return &lobby; + } + } + return nullptr; + } + + Lobby* Lobbies::FindMutable(const LWOOBJID player) { + return const_cast(Find(player)); + } + + bool Lobbies::Join(const LWOOBJID player, const std::string& name, const std::string& choices, const ActivitySettings& settings, std::vector& out) { + if (const auto* current = Find(player)) { + if (current->settings.activityID == settings.activityID && current->settings.instanceMapID == settings.instanceMapID) return false; + // Waiting for something else: that lobby loses them + Leave(player, out); + } + + Lobby* lobby = nullptr; + for (auto& candidate : m_Lobbies | std::views::values) { + if (candidate.settings.activityID == settings.activityID && candidate.settings.instanceMapID == settings.instanceMapID + && candidate.players.size() < candidate.settings.Capacity()) { + lobby = &candidate; + break; + } + } + if (!lobby) { + lobby = &m_Lobbies[m_NextLobbyID++]; + lobby->settings = settings; + lobby->timer = settings.waitTime; + } + + const LobbyPlayer joining{ player, name, choices, false }; + const auto joinedText = PlayerAddedText(joining); + + // The joining player: themselves first, then everyone already waiting and whether they are ready (live order) + out.push_back({ player, eMatchUpdate::PLAYER_ADDED, joinedText }); + for (const auto& waiting : lobby->players) { + out.push_back({ player, eMatchUpdate::PLAYER_ADDED, PlayerAddedText(waiting) }); + if (waiting.ready) out.push_back({ player, eMatchUpdate::PLAYER_READY, PlayerText(waiting.id) }); + } + // Everyone already waiting: the new player + Broadcast(*lobby, eMatchUpdate::PLAYER_ADDED, joinedText, out); + + lobby->players.push_back(joining); + + // A countdown already running: the new player gets the time left + if (lobby->counting) out.push_back({ player, eMatchUpdate::PHASE_WAIT_READY, TimeText(lobby->timer) }); + return true; + } + + bool Lobbies::Leave(const LWOOBJID player, std::vector& out) { + for (auto it = m_Lobbies.begin(); it != m_Lobbies.end(); ++it) { + auto& players = it->second.players; + const auto found = std::ranges::find(players, player, &LobbyPlayer::id); + if (found == players.end()) continue; + + // Everyone in the lobby, the one leaving included + Broadcast(it->second, eMatchUpdate::PLAYER_REMOVED, PlayerText(player), out); + players.erase(found); + if (players.empty()) m_Lobbies.erase(it); + return true; + } + return false; + } + + bool Lobbies::SetReady(const LWOOBJID player, const bool ready, std::vector& out) { + auto* lobby = FindMutable(player); + if (!lobby) return false; + + std::ranges::find(lobby->players, player, &LobbyPlayer::id)->ready = ready; + Broadcast(*lobby, ready ? eMatchUpdate::PLAYER_READY : eMatchUpdate::PLAYER_NOT_READY, PlayerText(player), out); + return true; + } + + std::vector Lobbies::Tick(const float deltaTime, std::vector& out) { + std::vector matches; + for (auto it = m_Lobbies.begin(); it != m_Lobbies.end();) { + auto& lobby = it->second; + if (lobby.players.empty()) { + it = m_Lobbies.erase(it); + continue; + } + + // The countdown runs only while the lobby has enough players; it doesn't start over when one leaves + if (lobby.players.size() >= lobby.settings.Minimum()) { + if (!lobby.counting) { + lobby.counting = true; + Broadcast(lobby, eMatchUpdate::PHASE_WAIT_READY, TimeText(lobby.timer), out); + } + lobby.timer -= deltaTime; + + // Everyone ready: only the start delay is left + const bool allReady = std::ranges::all_of(lobby.players, &LobbyPlayer::ready); + if (allReady && lobby.timer > lobby.settings.startDelay) { + lobby.timer = lobby.settings.startDelay; + Broadcast(lobby, eMatchUpdate::PHASE_WAIT_START, TimeText(lobby.timer), out); + } + + if (lobby.timer <= 0.0f) { + Match match{ lobby.settings, {} }; + for (const auto& player : lobby.players) match.players.push_back(player.id); + matches.push_back(std::move(match)); + it = m_Lobbies.erase(it); + continue; + } + } + ++it; + } + return matches; + } +} diff --git a/dChatServer/Matchmaking.h b/dChatServer/Matchmaking.h new file mode 100644 index 000000000..2ac31d887 --- /dev/null +++ b/dChatServer/Matchmaking.h @@ -0,0 +1,99 @@ +#ifndef MATCHMAKING_H +#define MATCHMAKING_H + +#include +#include +#include +#include + +#include "dCommonVars.h" +#include "eMatchUpdate.h" + +/** + * Activity lobbies of every world, kept by the chat server (docs/Matchmaking.md). Players on different instances of a + * zone who join the same activity wait in the same lobby and are sent to the same activity instance. + * + * Only the state machine: no network, no game data. What it does is handed back as MatchUpdates to send to clients + * and Matches to start; ChatMatchmaking sends them. + */ +namespace Matchmaking { + // What the world read from the activity (CDClient Activities, with the world's own overrides) + struct ActivitySettings { + int32_t activityID{}; + uint32_t instanceMapID{}; + int32_t minTeams{}; + int32_t maxTeams{}; + int32_t minTeamSize{}; + int32_t maxTeamSize{}; + float waitTime{}; // seconds + float startDelay{}; // seconds + + // How many players a lobby holds: the team size, or the number of teams when every team is one player + uint32_t Capacity() const; + // How many players a lobby needs before its countdown runs + uint32_t Minimum() const; + }; + + // A game message MatchUpdate for one client + struct Update { + LWOOBJID to{}; + eMatchUpdate type{}; + std::string data; // name-value text + }; + + // A lobby whose countdown ran out: these players go to one new instance of the activity's zone + struct Match { + ActivitySettings settings; + std::vector players; // in the order they joined + }; + + struct LobbyPlayer { + LWOOBJID id{}; + std::string name; + std::string choices; // the client's name-value text, shown to the others with the player + bool ready{}; + }; + + struct Lobby { + ActivitySettings settings; + std::vector players; + float timer{}; // seconds left + bool counting{}; // the lobby reached its minimum and everyone was told the countdown + }; + + class Lobbies { + public: + // A player joins the activity: they go into the first lobby of it with room, or a new one. + // Returns false when they are already waiting for this activity (nothing changes). + bool Join(LWOOBJID player, const std::string& name, const std::string& choices, const ActivitySettings& settings, std::vector& out); + + // The player leaves the lobby they are in. Returns false when they are in none. + bool Leave(LWOOBJID player, std::vector& out); + + // The player is ready (or not). Returns false when they are in no lobby. + bool SetReady(LWOOBJID player, bool ready, std::vector& out); + + // Runs the countdowns; returns the lobbies that start now (they are removed) + std::vector Tick(float deltaTime, std::vector& out); + + bool IsWaiting(LWOOBJID player) const { return Find(player) != nullptr; } + const Lobby* Find(LWOOBJID player) const; + const std::map& GetLobbies() const { return m_Lobbies; } + void Clear() { m_Lobbies.clear(); } + + private: + Lobby* FindMutable(LWOOBJID player); + static void Broadcast(const Lobby& lobby, eMatchUpdate type, const std::string& data, std::vector& out); + + // In the order they were made, so a joining player fills the oldest lobby first + std::map m_Lobbies; + uint64_t m_NextLobbyID = 1; + }; + + // The name-value texts of the updates, as live sent them + std::string PlayerText(LWOOBJID player); + std::string PlayerAddedText(const LobbyPlayer& player); + std::string TimeText(float seconds); +} + +#endif // MATCHMAKING_H diff --git a/tests/dGameTests/CMakeLists.txt b/tests/dGameTests/CMakeLists.txt index 33deaa1a9..5637b6f51 100644 --- a/tests/dGameTests/CMakeLists.txt +++ b/tests/dGameTests/CMakeLists.txt @@ -4,6 +4,7 @@ set(DGAMETEST_SOURCES "LUBitStreamTests.cpp" "MailIdPinTests.cpp" "MailPacketTests.cpp" + "MatchmakingTests.cpp" "EconomyLedgerTests.cpp" "PlayerReportsLimitTests.cpp" "SlashCommandPermissionTests.cpp" diff --git a/tests/dGameTests/MatchmakingTests.cpp b/tests/dGameTests/MatchmakingTests.cpp new file mode 100644 index 000000000..6a9195b85 --- /dev/null +++ b/tests/dGameTests/MatchmakingTests.cpp @@ -0,0 +1,292 @@ +#include "Matchmaking.h" + +#include + +#include + +// The chat server's activity lobbies (docs/Matchmaking.md) + +using namespace Matchmaking; + +namespace { + constexpr LWOOBJID A = 0x1000000000000001LL; + constexpr LWOOBJID B = 0x1000000000000002LL; + constexpr LWOOBJID C = 0x1000000000000003LL; + constexpr LWOOBJID D = 0x1000000000000004LL; + constexpr LWOOBJID E = 0x1000000000000005LL; + + // Avant Gardens survival: one team of 1 to 4, a minute to wait, 3 seconds once everyone is ready + ActivitySettings Survival() { + ActivitySettings settings; + settings.activityID = 5; + settings.instanceMapID = 1101; + settings.minTeams = 1; + settings.maxTeams = 1; + settings.minTeamSize = 2; + settings.maxTeamSize = 4; + settings.waitTime = 60.0f; + settings.startDelay = 3.0f; + return settings; + } + + // A race: up to 6 teams of one, at least 2 + ActivitySettings Race() { + ActivitySettings settings; + settings.activityID = 42; + settings.instanceMapID = 1203; + settings.minTeams = 2; + settings.maxTeams = 6; + settings.minTeamSize = 1; + settings.maxTeamSize = 1; + settings.waitTime = 60.0f; + settings.startDelay = 3.0f; + return settings; + } + + std::vector For(const std::vector& updates, LWOOBJID player) { + std::vector result; + for (const auto& update : updates) if (update.to == player) result.push_back(update); + return result; + } + + size_t Count(const std::vector& updates, eMatchUpdate type) { + return std::ranges::count(updates, type, &Update::type); + } +} + +TEST(MatchmakingTests, SettingsCapacityAndMinimum) { + EXPECT_EQ(Survival().Capacity(), 4u); + EXPECT_EQ(Survival().Minimum(), 2u); + EXPECT_EQ(Race().Capacity(), 6u); + EXPECT_EQ(Race().Minimum(), 2u); + ActivitySettings empty; + EXPECT_EQ(empty.Capacity(), 1u); + EXPECT_EQ(empty.Minimum(), 1u); +} + +TEST(MatchmakingTests, UpdateTextsMatchLiveCaptures) { + EXPECT_EQ(PlayerText(1152921510436607007LL), "player=9:1152921510436607007"); + EXPECT_EQ(PlayerAddedText({ 1152921510436607007LL, "GruntMonkey", "", false }), "player=9:1152921510436607007\nplayerName=0:GruntMonkey"); + // The racing car the client chose comes first, as an object ID + EXPECT_EQ(PlayerAddedText({ 1152921510436607007LL, "GruntMonkey", "droppedItem=13:1152921510659010409", false }), + "droppedItem=9:1152921510659010409\nplayer=9:1152921510436607007\nplayerName=0:GruntMonkey"); + EXPECT_EQ(PlayerAddedText({ 1, "x", "somethingElse=0:1\ndroppedItem=13:notanumber", false }), "player=9:1\nplayerName=0:x"); + EXPECT_EQ(TimeText(3.0f).rfind("time=3:", 0), 0u); +} + +TEST(MatchmakingTests, JoinSendsTheJoinerThemselvesFirstThenTheOthers) { + Lobbies lobbies; + std::vector out; + ASSERT_TRUE(lobbies.Join(A, "Alpha", "", Survival(), out)); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].to, A); + EXPECT_EQ(out[0].type, eMatchUpdate::PLAYER_ADDED); + EXPECT_EQ(out[0].data, "player=9:" + std::to_string(A) + "\nplayerName=0:Alpha"); + + out.clear(); + ASSERT_TRUE(lobbies.SetReady(A, true, out)); + out.clear(); + ASSERT_TRUE(lobbies.Join(B, "Bravo", "", Survival(), out)); + // B: B added, A added, A ready. A: B added. + const auto toB = For(out, B); + ASSERT_EQ(toB.size(), 3u); + EXPECT_EQ(toB[0].type, eMatchUpdate::PLAYER_ADDED); + EXPECT_NE(toB[0].data.find("Bravo"), std::string::npos); + EXPECT_EQ(toB[1].type, eMatchUpdate::PLAYER_ADDED); + EXPECT_NE(toB[1].data.find("Alpha"), std::string::npos); + EXPECT_EQ(toB[2].type, eMatchUpdate::PLAYER_READY); + EXPECT_EQ(toB[2].data, PlayerText(A)); + const auto toA = For(out, A); + ASSERT_EQ(toA.size(), 1u); + EXPECT_EQ(toA[0].type, eMatchUpdate::PLAYER_ADDED); + EXPECT_NE(toA[0].data.find("Bravo"), std::string::npos); + + // Joining again changes nothing + out.clear(); + EXPECT_FALSE(lobbies.Join(B, "Bravo", "", Survival(), out)); + EXPECT_TRUE(out.empty()); + EXPECT_EQ(lobbies.GetLobbies().size(), 1u); +} + +TEST(MatchmakingTests, CountdownStartsAtTheMinimumAndLateJoinersGetTheTimeLeft) { + Lobbies lobbies; + std::vector out; + lobbies.Join(A, "Alpha", "", Survival(), out); + out.clear(); + // Below the minimum nothing runs + EXPECT_TRUE(lobbies.Tick(30.0f, out).empty()); + EXPECT_TRUE(out.empty()); + EXPECT_FLOAT_EQ(lobbies.Find(A)->timer, 60.0f); + + lobbies.Join(B, "Bravo", "", Survival(), out); + EXPECT_EQ(Count(out, eMatchUpdate::PHASE_WAIT_READY), 0u); + out.clear(); + EXPECT_TRUE(lobbies.Tick(10.0f, out).empty()); + // Everyone is told the countdown once + EXPECT_EQ(Count(out, eMatchUpdate::PHASE_WAIT_READY), 2u); + EXPECT_EQ(For(out, A)[0].data, TimeText(60.0f)); + EXPECT_FLOAT_EQ(lobbies.Find(A)->timer, 50.0f); + out.clear(); + lobbies.Tick(1.0f, out); + EXPECT_TRUE(out.empty()); + + lobbies.Join(C, "Charlie", "", Survival(), out); + const auto toC = For(out, C); + ASSERT_FALSE(toC.empty()); + EXPECT_EQ(toC.back().type, eMatchUpdate::PHASE_WAIT_READY); + EXPECT_EQ(toC.back().data, TimeText(49.0f)); +} + +TEST(MatchmakingTests, TimerRunsOutIntoOneMatchWithEveryoneInJoinOrder) { + Lobbies lobbies; + std::vector out; + lobbies.Join(A, "Alpha", "", Survival(), out); + lobbies.Join(B, "Bravo", "", Survival(), out); + lobbies.Join(C, "Charlie", "", Survival(), out); + EXPECT_TRUE(lobbies.Tick(59.0f, out).empty()); + const auto matches = lobbies.Tick(1.5f, out); + ASSERT_EQ(matches.size(), 1u); + EXPECT_EQ(matches[0].players, (std::vector{ A, B, C })); + EXPECT_EQ(matches[0].settings.instanceMapID, 1101u); + EXPECT_TRUE(lobbies.GetLobbies().empty()); + EXPECT_FALSE(lobbies.IsWaiting(A)); +} + +TEST(MatchmakingTests, EveryoneReadyCutsTheTimerToTheStartDelay) { + Lobbies lobbies; + std::vector out; + lobbies.Join(A, "Alpha", "", Survival(), out); + lobbies.Join(B, "Bravo", "", Survival(), out); + lobbies.Tick(1.0f, out); + lobbies.SetReady(A, true, out); + out.clear(); + lobbies.SetReady(B, true, out); + EXPECT_EQ(Count(out, eMatchUpdate::PLAYER_READY), 2u); + out.clear(); + EXPECT_TRUE(lobbies.Tick(1.0f, out).empty()); + EXPECT_EQ(Count(out, eMatchUpdate::PHASE_WAIT_START), 2u); + EXPECT_EQ(For(out, A)[0].data, TimeText(3.0f)); + EXPECT_FLOAT_EQ(lobbies.Find(A)->timer, 3.0f); + EXPECT_TRUE(lobbies.Tick(2.0f, out).empty()); + EXPECT_EQ(lobbies.Tick(1.0f, out).size(), 1u); +} + +TEST(MatchmakingTests, ReadyBelowTheMinimumDoesNotCutTheTimer) { + Lobbies lobbies; + std::vector out; + lobbies.Join(A, "Alpha", "", Survival(), out); + lobbies.SetReady(A, true, out); + lobbies.Tick(1.0f, out); + EXPECT_FLOAT_EQ(lobbies.Find(A)->timer, 60.0f); +} + +TEST(MatchmakingTests, UnreadyIsBroadcast) { + Lobbies lobbies; + std::vector out; + lobbies.Join(A, "Alpha", "", Survival(), out); + lobbies.Join(B, "Bravo", "", Survival(), out); + out.clear(); + lobbies.SetReady(B, false, out); + EXPECT_EQ(Count(out, eMatchUpdate::PLAYER_NOT_READY), 2u); + EXPECT_FALSE(lobbies.SetReady(C, true, out)); +} + +TEST(MatchmakingTests, LeaveTellsEveryoneIncludingTheLeaverAndEmptyLobbiesGo) { + Lobbies lobbies; + std::vector out; + lobbies.Join(A, "Alpha", "", Survival(), out); + lobbies.Join(B, "Bravo", "", Survival(), out); + out.clear(); + ASSERT_TRUE(lobbies.Leave(B, out)); + ASSERT_EQ(out.size(), 2u); + EXPECT_EQ(Count(out, eMatchUpdate::PLAYER_REMOVED), 2u); + EXPECT_EQ(out[0].data, PlayerText(B)); + EXPECT_FALSE(lobbies.IsWaiting(B)); + EXPECT_FALSE(lobbies.Leave(B, out)); + ASSERT_TRUE(lobbies.Leave(A, out)); + EXPECT_TRUE(lobbies.GetLobbies().empty()); +} + +TEST(MatchmakingTests, CountdownPausesBelowTheMinimumWithoutStartingOver) { + Lobbies lobbies; + std::vector out; + lobbies.Join(A, "Alpha", "", Survival(), out); + lobbies.Join(B, "Bravo", "", Survival(), out); + lobbies.Tick(20.0f, out); + lobbies.Leave(B, out); + lobbies.Tick(20.0f, out); + EXPECT_FLOAT_EQ(lobbies.Find(A)->timer, 40.0f); + out.clear(); + lobbies.Join(C, "Charlie", "", Survival(), out); + EXPECT_EQ(For(out, C).back().data, TimeText(40.0f)); +} + +TEST(MatchmakingTests, FullLobbiesOverflowIntoANewOne) { + Lobbies lobbies; + std::vector out; + for (const auto player : { A, B, C, D }) lobbies.Join(player, "p", "", Survival(), out); + EXPECT_EQ(lobbies.GetLobbies().size(), 1u); + lobbies.Join(E, "e", "", Survival(), out); + EXPECT_EQ(lobbies.GetLobbies().size(), 2u); + EXPECT_NE(lobbies.Find(A), lobbies.Find(E)); + + // A place in the first lobby is taken before the second one + lobbies.Leave(B, out); + constexpr LWOOBJID F = 0x1000000000000006LL; + lobbies.Join(F, "f", "", Survival(), out); + EXPECT_EQ(lobbies.Find(F), lobbies.Find(A)); +} + +TEST(MatchmakingTests, RacesHoldSixSingleTeams) { + Lobbies lobbies; + std::vector out; + for (LWOOBJID player = 1; player <= 6; player++) lobbies.Join(player, "p", "", Race(), out); + EXPECT_EQ(lobbies.GetLobbies().size(), 1u); + lobbies.Join(7, "p", "", Race(), out); + EXPECT_EQ(lobbies.GetLobbies().size(), 2u); +} + +TEST(MatchmakingTests, DifferentActivitiesAndZonesWaitApart) { + Lobbies lobbies; + std::vector out; + lobbies.Join(A, "Alpha", "", Survival(), out); + lobbies.Join(B, "Bravo", "", Race(), out); + auto otherZone = Survival(); + otherZone.instanceMapID = 1102; + lobbies.Join(C, "Charlie", "", otherZone, out); + EXPECT_EQ(lobbies.GetLobbies().size(), 3u); + + // Joining another activity leaves the first lobby + out.clear(); + ASSERT_TRUE(lobbies.Join(A, "Alpha", "", Race(), out)); + EXPECT_EQ(Count(out, eMatchUpdate::PLAYER_REMOVED), 1u); + EXPECT_EQ(lobbies.Find(A), lobbies.Find(B)); + EXPECT_EQ(lobbies.GetLobbies().size(), 2u); +} + +TEST(MatchmakingTests, SoloActivityStartsAfterTheWaitAlone) { + // Solo racing (the world's override): one player is enough + auto solo = Race(); + solo.minTeams = 1; + Lobbies lobbies; + std::vector out; + lobbies.Join(A, "Alpha", "", solo, out); + lobbies.SetReady(A, true, out); + EXPECT_TRUE(lobbies.Tick(0.5f, out).empty()); + EXPECT_FLOAT_EQ(lobbies.Find(A)->timer, 3.0f); + const auto matches = lobbies.Tick(3.0f, out); + ASSERT_EQ(matches.size(), 1u); + EXPECT_EQ(matches[0].players, std::vector{ A }); +} + +TEST(MatchmakingTests, SeveralLobbiesStartInTheSameTick) { + Lobbies lobbies; + std::vector out; + lobbies.Join(A, "Alpha", "", Survival(), out); + lobbies.Join(B, "Bravo", "", Survival(), out); + lobbies.Join(C, "Charlie", "", Race(), out); + lobbies.Join(D, "Delta", "", Race(), out); + const auto matches = lobbies.Tick(61.0f, out); + EXPECT_EQ(matches.size(), 2u); + EXPECT_TRUE(lobbies.GetLobbies().empty()); +}