From 289a1acfdfee87e8d4473e691724f71ccd0a2d27 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 04:12:00 -0500 Subject: [PATCH] feat(world): activity lobbies join, ready and leave through the chat server Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/ActivityComponent.cpp | 342 ++++------------------- dGame/dComponents/ActivityComponent.h | 88 ++---- dGame/dGameMessages/ActivityMessages.cpp | 15 +- dWorldServer/WorldServer.cpp | 13 + 4 files changed, 99 insertions(+), 359 deletions(-) diff --git a/dGame/dComponents/ActivityComponent.cpp b/dGame/dComponents/ActivityComponent.cpp index e03955373..5d3032186 100644 --- a/dGame/dComponents/ActivityComponent.cpp +++ b/dGame/dComponents/ActivityComponent.cpp @@ -7,7 +7,6 @@ #include "MissionComponent.h" #include "Character.h" #include "dZoneManager.h" -#include "ZoneInstanceManager.h" #include "Game.h" #include "Logger.h" #include "ClientPackets.h" @@ -25,7 +24,6 @@ #include "eMatchUpdate.h" #include "ServiceType.h" #include "MessageType/Chat.h" -#include "ObjectIDManager.h" #include "CDActivityRewardsTable.h" #include "CDActivitiesTable.h" @@ -113,196 +111,72 @@ void ActivityComponent::HandleMessageBoxResponse(Entity* player, const std::stri } } -void ActivityComponent::PlayerJoin(Entity* player) { - if (PlayerIsInQueue(player)) return; +void ActivityComponent::PlayerJoin(Entity* player, const std::string& playerChoices) { // If we have a lobby, queue the player and allow others to join, otherwise spin up an instance on the spot if (HasLobby()) { - PlayerJoinLobby(player); + PlayerJoinLobby(player, playerChoices); } else if (!IsPlayedBy(player)) { NewInstance().AddParticipant(player); } } -void ActivityComponent::PlayerJoinLobby(Entity* player) { +namespace { + void SendMatchRequest(const LWOOBJID playerID, const ChatPackets::eMatchRequestType type, const int32_t value) { + ChatPackets::MatchRequest request; + request.playerID = playerID; + request.type = type; + request.value = value; + ChatServerLink::Send(request); + } +} + +void ActivityComponent::PlayerJoinLobby(Entity* player, const std::string& playerChoices) { if (!m_Parent->HasComponent(eReplicaComponentType::QUICK_BUILD)) { GameMessages::MatchResponse matchResponse; matchResponse.target = player->GetObjectID(); matchResponse.response = 0; matchResponse.SendToClient(player->GetSystemAddress()); // tell the client they joined a lobby } - LobbyPlayer newLobbyPlayer{}; - newLobbyPlayer.entityID = player->GetObjectID(); - LWOOBJID playerLobbyID = LWOOBJID_EMPTY; auto* character = player->GetCharacter(); if (character != nullptr) character->SetLastNonInstanceZoneID(Game::zoneManager->GetZone()->GetWorldID()); - for (auto& [lobbyID, lobby] : m_Queue) { - if (lobby.players.size() < m_ActivityInfo.maxTeamSize || m_ActivityInfo.maxTeamSize == 1 && lobby.players.size() < m_ActivityInfo.maxTeams) { - // If an empty slot in an existing lobby is found - lobby.players.push_back(newLobbyPlayer); - playerLobbyID = lobbyID; - - // Update the joining player on players already in the lobby, and update players already in the lobby on the joining player - LDFData playerLDF("player", player->GetObjectID()); - LDFData playerName("playerName", player->GetCharacter()->GetName()); - std::string matchUpdateJoined = playerLDF.GetString() + "\n" + playerName.GetString(); - for (const auto& joinedPlayer : lobby.players) { - auto* const entity = joinedPlayer.GetEntity(); - - if (entity == nullptr) { - continue; - } - - LDFData entityLDF("player", entity->GetObjectID()); - LDFData entityName("playerName", entity->GetCharacter()->GetName()); - std::string matchUpdate = entityLDF.GetString() + "\n" + entityName.GetString(); - GameMessages::MatchUpdate existingPlayerUpdate; - existingPlayerUpdate.target = player->GetObjectID(); - existingPlayerUpdate.data = matchUpdate; - existingPlayerUpdate.type = eMatchUpdate::PLAYER_ADDED; - existingPlayerUpdate.SendToClient(player->GetSystemAddress()); - PlayerReady(entity, joinedPlayer.ready); - GameMessages::MatchUpdate joinedPlayerUpdate; - joinedPlayerUpdate.target = entity->GetObjectID(); - joinedPlayerUpdate.data = matchUpdateJoined; - joinedPlayerUpdate.type = eMatchUpdate::PLAYER_ADDED; - joinedPlayerUpdate.SendToClient(entity->GetSystemAddress()); - } - break; - } - } - - if (playerLobbyID == LWOOBJID_EMPTY) { - // If all lobbies are full - playerLobbyID = ObjectIDManager::GenerateObjectID(); - auto& newLobby = m_Queue[playerLobbyID]; - newLobby.players.push_back(newLobbyPlayer); - newLobby.timer = m_ActivityInfo.waitTime / 1000; - } - const auto& lobby = m_Queue[playerLobbyID]; - - if (m_ActivityInfo.maxTeamSize != 1 && lobby.players.size() >= m_ActivityInfo.minTeamSize || m_ActivityInfo.maxTeamSize == 1 && lobby.players.size() >= m_ActivityInfo.minTeams) { - // Update the joining player on the match timer - LDFData matchTimer("time", lobby.timer); - GameMessages::MatchUpdate matchUpdate; - matchUpdate.target = player->GetObjectID(); - matchUpdate.data = matchTimer.GetString(); - matchUpdate.type = eMatchUpdate::PHASE_WAIT_READY; - matchUpdate.SendToClient(player->GetSystemAddress()); - } + // The lobbies are the chat server's, so players of every instance of this zone wait together (docs/Matchmaking.md). + // It gets what this world read from the activity, overrides (solo racing, transfer zone) included. + ChatPackets::MatchRequest request; + request.playerID = player->GetObjectID(); + request.type = ChatPackets::eMatchRequestType::JOIN; + request.activityID = m_ActivityInfo.ActivityID; + request.playerName = character != nullptr ? character->GetName() : ""; + request.playerChoices = playerChoices; + request.instanceMapID = m_ActivityInfo.instanceMapID; + request.minTeams = m_ActivityInfo.minTeams; + request.maxTeams = m_ActivityInfo.maxTeams; + request.minTeamSize = m_ActivityInfo.minTeamSize; + request.maxTeamSize = m_ActivityInfo.maxTeamSize; + request.waitTime = m_ActivityInfo.waitTime; + request.startDelay = m_ActivityInfo.startDelay; + ChatServerLink::Send(request); } void ActivityComponent::PlayerLeave(LWOOBJID playerID) { - // Removes the player from a lobby and notifies the others, not applicable for non-lobby instances - for (auto& lobby : m_Queue | std::views::values) { - for (int i = 0; i < lobby.players.size(); i++) { - const auto& player = lobby.players[i]; - if (player.entityID == playerID) { - LDFData matchUpdateLeft("player", playerID); - for (const auto& lobbyPlayer : lobby.players) { - auto* const entity = lobbyPlayer.GetEntity(); - if (entity == nullptr) - continue; - - GameMessages::MatchUpdate matchUpdate; - matchUpdate.target = entity->GetObjectID(); - matchUpdate.data = matchUpdateLeft.GetString(); - matchUpdate.type = eMatchUpdate::PLAYER_REMOVED; - matchUpdate.SendToClient(entity->GetSystemAddress()); - } - - lobby.players.erase(lobby.players.begin() + i); - - return; - } - } - } + // Not applicable for non-lobby instances + if (!HasLobby()) return; + SendMatchRequest(playerID, ChatPackets::eMatchRequestType::LEAVE, 0); } -void ActivityComponent::Update(float deltaTime) { - std::vector lobbiesToRemove{}; - // Ticks all the lobbies, not applicable for non-instance activities - for (auto& [lobbyID, lobby] : m_Queue) { - for (const auto& player : lobby.players) { - const auto* const entity = player.GetEntity(); - if (entity == nullptr) { - PlayerLeave(player.entityID); - return; - } - } - - if (lobby.players.empty()) { - lobbiesToRemove.push_back(lobbyID); +void ActivityComponent::StartMatch(const ChatPackets::MatchTransfer& transfer) { + auto& instance = NewInstance(); + for (const auto playerID : transfer.players) { + auto* const entity = Game::entityManager->GetEntity(playerID); + if (entity == nullptr || !CheckCost(entity)) { continue; } - // Update the match time for all players - if (m_ActivityInfo.maxTeamSize != 1 && lobby.players.size() >= m_ActivityInfo.minTeamSize - || m_ActivityInfo.maxTeamSize == 1 && lobby.players.size() >= m_ActivityInfo.minTeams) { - if (lobby.timer == m_ActivityInfo.waitTime / 1000) { - for (const auto& joinedPlayer : lobby.players) { - auto* const entity = joinedPlayer.GetEntity(); - - if (entity == nullptr) - continue; - - LDFData matchTimerUpdate("time", lobby.timer); - GameMessages::MatchUpdate matchUpdate; - matchUpdate.target = entity->GetObjectID(); - matchUpdate.data = matchTimerUpdate.GetString(); - matchUpdate.type = eMatchUpdate::PHASE_WAIT_READY; - matchUpdate.SendToClient(entity->GetSystemAddress()); - } - } - - lobby.timer -= deltaTime; - } - - bool lobbyReady = true; - for (const auto& player : lobby.players) { - if (player.ready) continue; - lobbyReady = false; - } - - // If everyone's ready, jump the timer - if (lobbyReady && lobby.timer > m_ActivityInfo.startDelay / 1000) { - lobby.timer = m_ActivityInfo.startDelay / 1000; - - // Update players in lobby on switch to start delay - LDFData matchTimerUpdate("time", lobby.timer); - for (const auto& player : lobby.players) { - auto* const entity = player.GetEntity(); - - if (entity == nullptr) - continue; - - GameMessages::MatchUpdate matchUpdate; - matchUpdate.target = entity->GetObjectID(); - matchUpdate.data = matchTimerUpdate.GetString(); - matchUpdate.type = eMatchUpdate::PHASE_WAIT_START; - matchUpdate.SendToClient(entity->GetSystemAddress()); - } - } - - // The timer has elapsed, start the instance - if (lobby.timer <= 0.0f) { - LOG("Setting up instance."); - auto& instance = NewInstance(); - LoadPlayersIntoInstance(instance, lobby.players); - instance.StartZone(); - lobbiesToRemove.push_back(lobbyID); - } + instance.AddParticipant(entity); } - - for (const auto id : lobbiesToRemove) { - RemoveLobby(id); - } -} - -void ActivityComponent::RemoveLobby(const LWOOBJID lobbyID) { - if (m_Queue.contains(lobbyID)) m_Queue.erase(lobbyID); + instance.TransferParticipants(transfer); } bool ActivityComponent::HasLobby() const { @@ -310,16 +184,6 @@ bool ActivityComponent::HasLobby() const { return m_ActivityInfo.instanceMapID != UINT_MAX && m_ActivityInfo.instanceMapID != Game::server->GetZoneID(); } -bool ActivityComponent::PlayerIsInQueue(Entity* player) { - for (const auto& lobby : m_Queue | std::views::values) { - for (const auto& lobbyPlayer : lobby.players) { - if (player->GetObjectID() == lobbyPlayer.entityID) return true; - } - } - - return false; -} - bool ActivityComponent::IsPlayedBy(Entity* player) const { for (const auto& instance : m_Instances) { for (const auto* instancePlayer : instance.GetParticipants()) { @@ -362,30 +226,7 @@ bool ActivityComponent::TakeCost(Entity* player) const { } void ActivityComponent::PlayerReady(Entity* player, bool bReady) { - for (auto& lobby : m_Queue | std::views::values) { - for (auto& lobbyPlayer : lobby.players) { - if (lobbyPlayer.entityID == player->GetObjectID()) { - - lobbyPlayer.ready = bReady; - - // Update players in lobby on player being ready - LDFData matchReadyUpdate("player", player->GetObjectID()); - eMatchUpdate readyStatus = eMatchUpdate::PLAYER_READY; - if (!bReady) readyStatus = eMatchUpdate::PLAYER_NOT_READY; - for (const auto& otherPlayer : lobby.players) { - auto* const entity = otherPlayer.GetEntity(); - if (entity == nullptr) - continue; - - GameMessages::MatchUpdate matchUpdate; - matchUpdate.target = entity->GetObjectID(); - matchUpdate.data = matchReadyUpdate.GetString(); - matchUpdate.type = readyStatus; - matchUpdate.SendToClient(entity->GetSystemAddress()); - } - } - } - } + SendMatchRequest(player->GetObjectID(), ChatPackets::eMatchRequestType::READY, bReady ? 1 : 0); } ActivityInstance& ActivityComponent::NewInstance() { @@ -393,17 +234,6 @@ ActivityInstance& ActivityComponent::NewInstance() { return m_Instances.back(); } -void ActivityComponent::LoadPlayersIntoInstance(ActivityInstance& instance, const std::vector& lobby) const { - for (const auto& player : lobby) { - auto* const entity = player.GetEntity(); - if (entity == nullptr || !CheckCost(entity)) { - continue; - } - - instance.AddParticipant(entity); - } -} - const ActivityInstance& ActivityComponent::GetInstance(const LWOOBJID playerID) const { for (const auto& instance : m_Instances) { for (const auto* participant : instance.GetParticipants()) { @@ -462,59 +292,28 @@ void ActivityComponent::PlayerRemove(LWOOBJID playerID) { } } -void ActivityInstance::StartZone() { - if (m_Participants.empty()) - return; - - const auto& participants = GetParticipants(); - if (participants.empty()) - return; - - auto* leader = participants[0]; - LWOZONEID zoneId = LWOZONEID(m_ActivityInfo.instanceMapID, 0, leader->GetCharacter()->GetPropertyCloneID()); - - // only make a team if we have more than one participant - if (participants.size() > 1) { - ChatPackets::CreateTeam createTeam; - createTeam.leaderID = leader->GetObjectID(); - - for (const auto& participant : m_Participants) { - createTeam.members.push_back(participant); - } - - createTeam.zoneID = zoneId; - - ChatServerLink::Send(createTeam); - } - - const auto cloneId = GeneralUtils::GenerateRandomNumber(1, UINT32_MAX); - for (Entity* player : participants) { - const auto objid = player->GetObjectID(); - ZoneInstanceManager::Instance()->RequestZoneTransfer(Game::server, m_ActivityInfo.instanceMapID, cloneId, false, [objid](bool mythranShift, uint32_t zoneID, uint32_t zoneInstance, uint32_t zoneClone, std::string serverIP, uint16_t serverPort) { - - auto* player = Game::entityManager->GetEntity(objid); - if (player == nullptr) - return; - - LOG("Transferring %s to Zone %i (Instance %i | Clone %i | Mythran Shift: %s) with IP %s and Port %i", player->GetCharacter()->GetName().c_str(), zoneID, zoneInstance, zoneClone, mythranShift == true ? "true" : "false", serverIP.c_str(), serverPort); - if (player->GetCharacter()) { - auto* characterComponent = player->GetComponent(); - if (characterComponent) { - characterComponent->AddVisitedLevel(LWOZONEID(zoneID, LWOINSTANCEID_INVALID, zoneClone)); - } - - player->GetCharacter()->SetZoneID(zoneID); - player->GetCharacter()->SetZoneInstance(zoneInstance); - player->GetCharacter()->SetZoneClone(zoneClone); +void ActivityInstance::TransferParticipants(const ChatPackets::MatchTransfer& transfer) { + const auto zoneID = transfer.zoneID.GetMapID(); + const auto zoneInstance = transfer.zoneID.GetInstanceID(); + const auto zoneClone = transfer.zoneID.GetCloneID(); + for (Entity* player : GetParticipants()) { + LOG("Transferring %s to Zone %i (Instance %i | Clone %i | Mythran Shift: %s) with IP %s and Port %i", player->GetCharacter()->GetName().c_str(), zoneID, zoneInstance, zoneClone, transfer.mythranShift == true ? "true" : "false", transfer.serverIP.c_str(), transfer.serverPort); + if (player->GetCharacter()) { + auto* characterComponent = player->GetComponent(); + if (characterComponent) { + characterComponent->AddVisitedLevel(LWOZONEID(zoneID, LWOINSTANCEID_INVALID, zoneClone)); } - ClientPackets::TransferToWorld transfer; - transfer.serverIP = LUString(serverIP); - transfer.serverPort = serverPort; - transfer.mythranShift = mythranShift; - transfer.Send(player->GetSystemAddress()); - return; - }); + player->GetCharacter()->SetZoneID(zoneID); + player->GetCharacter()->SetZoneInstance(zoneInstance); + player->GetCharacter()->SetZoneClone(zoneClone); + } + + ClientPackets::TransferToWorld transferToWorld; + transferToWorld.serverIP = LUString(transfer.serverIP); + transferToWorld.serverPort = transfer.serverPort; + transferToWorld.mythranShift = transfer.mythranShift; + transferToWorld.Send(player->GetSystemAddress()); } m_NextZoneCloneID++; @@ -573,10 +372,6 @@ void ActivityInstance::SetScore(uint32_t score) { this->score = score; } -Entity* LobbyPlayer::GetEntity() const { - return Game::entityManager->GetEntity(entityID); -} - bool ActivityComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportInfo) { auto& activityInfo = reportInfo.info->PushDebug("Activity"); @@ -620,23 +415,6 @@ bool ActivityComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& } } - auto& queue = activityInfo.PushDebug("Queue"); - i = 0; - for (const auto& lobbyQueue : m_Queue | std::views::values) { - auto& lobby = queue.PushDebug("Lobby " + std::to_string(i++)); - lobby.PushDebug("Timer") = lobbyQueue.timer; - - auto& players = lobby.PushDebug("Players"); - for (const auto& player : lobbyQueue.players) { - const auto* const playerEntity = player.GetEntity(); - if (!playerEntity) continue; - auto* character = playerEntity->GetCharacter(); - if (!character) continue; - - players.PushDebug(std::to_string(playerEntity->GetObjectID()) + ": " + character->GetName()) = player.ready ? "Ready" : "Not Ready"; - } - } - auto& activityPlayers = activityInfo.PushDebug("Activity Players"); for (const auto& [playerID, playerScores] : m_ActivityPlayers) { auto* const activityPlayerEntity = Game::entityManager->GetEntity(playerID); diff --git a/dGame/dComponents/ActivityComponent.h b/dGame/dComponents/ActivityComponent.h index 856ca70f6..55bd7d698 100644 --- a/dGame/dComponents/ActivityComponent.h +++ b/dGame/dComponents/ActivityComponent.h @@ -14,6 +14,10 @@ namespace GameMessages { class GameMsg; }; +namespace ChatPackets { + struct MatchTransfer; +}; + /** * Represents an instance of an activity, having participants and score */ @@ -34,9 +38,10 @@ public: void ClearParticipants() { m_Participants.clear(); }; /** - * Starts the instance world for this activity and sends all participants there + * Sends the participants to the activity's instance that master started (the chat server's matchmaking found it) + * @param transfer the instance's zone, address and the players chat sent here */ - void StartZone(); + void TransferParticipants(const ChatPackets::MatchTransfer& transfer); /** * Gives the rewards for completing this activity to some participant @@ -97,44 +102,6 @@ private: std::vector m_Participants; }; -/** - * Represents an entity in a lobby - */ -struct LobbyPlayer { - - /** - * The ID of the entity that is in the lobby - */ - LWOOBJID entityID = LWOOBJID_EMPTY; - - /** - * Whether or not the entity is ready - */ - bool ready = false; - - /** - * Returns the entity that is in the lobby - * @return the entity that is in the lobby - */ - Entity* GetEntity() const; -}; - -/** - * Represents a lobby of players with a timer until it should start the activity - */ -struct Lobby { - - /** - * The lobby of players - */ - std::vector players; - - /** - * The timer that determines when the activity should start - */ - float timer{}; -}; - /** * Represents the score for the player in an activity, one index might represent score, another one time, etc. */ @@ -163,23 +130,25 @@ public: void LoadActivityData(const int32_t activityId); - void Update(float deltaTime) override; void Serialize(RakNet::BitStream& outBitStream, bool bIsInitialUpdate) override; /** * Makes some entity join the minigame, if it's a lobbied one, the entity will be placed in the lobby * @param player the entity to join the game + * @param playerChoices the name-value text of the client's MatchRequest (a racing car), shown to the lobby */ - void PlayerJoin(Entity* player); + void PlayerJoin(Entity* player, const std::string& playerChoices = ""); /** - * Makes an entity join the lobby for this minigame, if it exists + * Makes an entity join the lobby for this minigame. The lobbies are the chat server's, across every world + * (docs/Matchmaking.md). * @param player the entity to join + * @param playerChoices the name-value text of the client's MatchRequest */ - void PlayerJoinLobby(Entity* player); + void PlayerJoinLobby(Entity* player, const std::string& playerChoices = ""); /** - * Makes the player leave the lobby + * Makes the player leave the lobby (the chat server's) * @param playerID the entity to leave the lobby */ void PlayerLeave(LWOOBJID playerID); @@ -191,24 +160,17 @@ public: void PlayerRemove(LWOOBJID playerID); /** - * Adds all the players to an instance of some activity - * @param instance the instance to load the players into - * @param lobby the players to load into the instance + * A lobby's match for this activity has its instance: the players of it in this world who can pay go there + * @param transfer the instance and the players, from the chat server */ - void LoadPlayersIntoInstance(ActivityInstance& instance, const std::vector& lobby) const; + void StartMatch(const ChatPackets::MatchTransfer& transfer); /** - * Removes a lobby from the activity manager - * @param lobby the lobby to remove - */ - void RemoveLobby(const LWOOBJID lobbyID); - - /** - * Marks a player as (un)ready in a lobby + * Marks a player as (un)ready in the lobby they wait in (the chat server knows which) * @param player the entity to mark * @param bReady true if the entity is ready, false otherwise */ - void PlayerReady(Entity* player, bool bReady); + static void PlayerReady(Entity* player, bool bReady); /** * Returns the ID of this activity @@ -226,13 +188,6 @@ public: */ bool HasLobby() const; - /** - * Checks if a player is currently waiting in a lobby - * @param player the entity to check for - * @return true if the entity is waiting in a lobby, false otherwise - */ - bool PlayerIsInQueue(Entity* player); - /** * Checks if an entity is currently playing this activity * @param player the entity to check @@ -334,11 +289,6 @@ private: */ std::vector m_Instances; - /** - * The current lobbies for this activity - */ - std::map m_Queue; - /** * All the activity score for the players in this activity */ diff --git a/dGame/dGameMessages/ActivityMessages.cpp b/dGame/dGameMessages/ActivityMessages.cpp index dc54e2f10..673fcca43 100644 --- a/dGame/dGameMessages/ActivityMessages.cpp +++ b/dGame/dGameMessages/ActivityMessages.cpp @@ -143,18 +143,17 @@ namespace GameMessages { auto* comp = scriptedAct->GetComponent(); if (!comp) continue; if (comp->GetActivityID() == value) { - comp->PlayerJoin(&entity); + comp->PlayerJoin(&entity, playerChoices); } } } } else if (type == 1) { // ready/unready - for (Entity* scriptedAct : scriptedActs) { - auto* comp = scriptedAct->GetComponent(); - if (!comp) continue; - if (comp->PlayerIsInQueue(&entity)) { - comp->PlayerReady(&entity, value); - } - } + // Answered like a join (live), then the chat server tells the lobby (docs/Matchmaking.md) + MatchResponse response; + response.target = entity.GetObjectID(); + response.response = 0; + response.SendToClient(sysAddr); + ActivityComponent::PlayerReady(&entity, value != 0); } } diff --git a/dWorldServer/WorldServer.cpp b/dWorldServer/WorldServer.cpp index 67e163866..f23f57056 100644 --- a/dWorldServer/WorldServer.cpp +++ b/dWorldServer/WorldServer.cpp @@ -69,6 +69,8 @@ #include "Entity.h" #include "Character.h" #include "ChatPackets.h" +#include "ScriptedActivityComponent.h" +#include "eReplicaComponentType.h" #include "WorldRoutePacket.h" #include "ChatServerLink.h" #include "PacketDispatcher.h" @@ -768,6 +770,17 @@ namespace { Game::entityManager->SerializeEntity(player); }); + // An activity lobby's match has its instance (docs/Matchmaking.md): send our players in it there + handlers.On(MessageType::Chat::MATCH_TRANSFER, [](const ChatPackets::MatchTransfer& transfer, const SystemAddress&) { + for (auto* entity : Game::entityManager->GetEntitiesByComponent(eReplicaComponentType::SCRIPTED_ACTIVITY)) { + auto* activity = entity->GetComponent(); + if (!activity || activity->GetActivityID() != transfer.activityID || !activity->HasLobby()) continue; + activity->StartMatch(transfer); + return; + } + LOG("Matchmaking: no activity %i in this world for a match of %zu player(s)", transfer.activityID, transfer.players.size()); + }); + // New mail for a player the chat server says is in this world handlers.On(MessageType::Chat::MAIL, [](const ChatPackets::MailNotify& notify, const SystemAddress&) { Mail::NotifyNewMailHere(notify.receiverID);