diff --git a/GenOnlineService/Constants.cs b/GenOnlineService/Constants.cs index d717c00..816149c 100644 --- a/GenOnlineService/Constants.cs +++ b/GenOnlineService/Constants.cs @@ -374,9 +374,15 @@ public enum EKnownClients static class WebSocketManager { public static int g_PeakConnectionCount = 0; - public static async Task CreateSession(AppDbContext _db, EUserSessionType sessionType, bool bIsReconnect, Int64 ownerID, KnownClients.EKnownClients client_id, string ipAddr, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin) + public static Task CreateSession(AppDbContext _db, EUserSessionType sessionType, bool bIsReconnect, Int64 ownerID, KnownClients.EKnownClients client_id, string ipAddr, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin) + { + return ServiceLocator.Services.GetRequiredService().OpenSessionAsync(_db, sessionType, bIsReconnect, ownerID, client_id, ipAddr, strContinent, strCountry, dLatitude, dLongitude, bIsAdmin); + } + + internal static async Task InstallSession(AppDbContext _db, EUserSessionType sessionType, bool bIsReconnect, Int64 ownerID, KnownClients.EKnownClients client_id, string ipAddr, string strContinent, string strCountry, double dLatitude, double dLongitude, bool bIsAdmin) { - string strDisplayName = await Database.Users.GetDisplayName(_db, ownerID); + string strDisplayName = await Database.Users.GetDisplayName(_db, ownerID); + await Database.Users.UpdateLastLoginData(_db, ownerID, ipAddr); // if we have cache data, that means its a reconnect, noraml connections go through login flows which reset cache data UserSession? userCacheData = WebSocketManager.GetSessionFromUser(ownerID, sessionType); @@ -430,13 +436,6 @@ public static async Task CreateSession(AppDbContext _db, // how many other sessions do they have online? bool bIsFirstSessionForUser = WebSocketManager.GetAllDataFromUser(ownerID).Count == 0; - // kill any existing sessions for this user of same session type - if (m_dictWebsockets[sessionType].TryGetValue(ownerID, out UserWebSocketInstance? existingSession)) - { - Console.WriteLine("Killing existing session for {0} ({1})", ownerID, strDisplayName); - await DeleteSession(ownerID, sessionType, existingSession, !bIsReconnect); - } - // get and cache social container UserSocialContainer socialContainer = new(); socialContainer.Friends = await Database.Social.GetFriends(_db, ownerID); @@ -490,8 +489,6 @@ public static async Task CreateSession(AppDbContext _db, UserWebSocketInstance newSess = new UserWebSocketInstance(sessionType, ownerID, userCacheData); m_dictWebsockets[sessionType][ownerID] = newSess; - // update last login and last ip - await Database.Users.UpdateLastLoginData(_db, ownerID, ipAddr); // TODO_EFCORE: Optimize this, dont iterate all the time int numSessions = WebSocketManager.GetNumberOfUsersOnline(); @@ -588,149 +585,48 @@ public static async Task CheckForTimeouts() } } - foreach (var userData in lstCacheEntriesToDestroy) - { - // A reconnect between the snapshot above and now would have replaced this user's - // session with a live one; only clear if the SAME session object is still registered - // and still abandoned+expired, so a fresh reconnect never has its live session torn - // down (kicked from its lobby, deregistered from matchmaking) by a stale sweep entry. - UserSession? currentSession = GetSessionFromUser(userData.UserID, userData.SessionType); - if (!ShouldStillClearAbandonedSession(currentSession, userData.Session)) - { - continue; - } - - await ClearDataFromUser(userData.UserID, userData.SessionType); - } - } - - public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionType, UserWebSocketInstance? oldWS, bool bShouldInvalidatePlayerCacheToBlockReconnect) - { - UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); - SharedUserData? sourceSharedData = WebSocketManager.GetSharedDataForUser(user_id); - - bool bIsStaleSocket = false; - - if (oldWS != null) - { - // has this websocket already been superseded by a newer connection for the same user? if so it is a - // stale/zombie socket and must not be allowed to abandon or tear down the live session - if (m_dictWebsockets[sessionType].TryGetValue(user_id, out UserWebSocketInstance? currentWS)) - { - bIsStaleSocket = !ReferenceEquals(currentWS, oldWS); - } - - try - { - // dont remove by ID, user could have re-opened another websocket open via reconnection, remove by instance, if its not there, thats OK, it was already closed and the new instance is a reconnect - var item = m_dictWebsockets[sessionType].First(kvp => kvp.Value == oldWS); // safe to lookup by sessionType here since we only ever remove old WS of the same type - m_dictWebsockets[sessionType].Remove(item.Key, out UserWebSocketInstance? destroyedSess); - - if (destroyedSess != null) - { - await destroyedSess.CloseAsync(WebSocketCloseStatus.NormalClosure, "User signed in from another point of presence [A]"); - } - } - catch - { - - } - - if (bIsStaleSocket) - { - // nothing else to do - the user is still connected on the newer socket - await oldWS.CloseAsync(WebSocketCloseStatus.NormalClosure, "Superseded by a newer connection"); - return; - } - } - - if (bShouldInvalidatePlayerCacheToBlockReconnect) - { - // NOTE: ClearDataFromUser owns the shared data ref count decrement (and GC) for this path - doing it - // again here would drop the count twice for a single disconnect and tear down the shared data while - // the user still has other live sessions - await WebSocketManager.ClearDataFromUser(user_id, sessionType); - } - else - { - // mark it as abandoned for now to start the expiration timer - if (sourceData != null) - { - sourceData.MarkAbandoned(); - - // If the player was in an active game when their connection dropped, record the - // abandon time NOW (before any lobby-structure cleanup runs). This timestamp is - // the authoritative "who quit first" signal used by DetermineLobbyWinnerIfNotPresent, - // and must be captured here rather than in RemoveMember, because RemoveMember may - // execute much later (e.g. 30 s after the first abandonment) or at an unexpected - // time (e.g. when the other player reconnects with a fresh session and their old - // session is forcibly evicted, causing them to appear as "last to leave" even - // though they stayed in the game longer than the opponent who quit first). - try - { - var lobbyManager = ServiceLocator.Services.GetRequiredService(); - if (sourceData.currentLobbyID != -1) - { - Lobby? ingameLobby = lobbyManager.GetLobby(sourceData.currentLobbyID); - if (ingameLobby != null && ingameLobby.State == ELobbyState.INGAME) - { - ingameLobby.RecordPlayerIngameAbandon(user_id); - } - else - { - Console.WriteLine($"[WARN] DeleteSession(false): skipped RecordPlayerIngameAbandon for user {user_id} — lobby={sourceData.currentLobbyID} found={ingameLobby != null} state={ingameLobby?.State}"); - } - } - else - { - Console.WriteLine($"[WARN] DeleteSession(false): skipped RecordPlayerIngameAbandon for user {user_id} — currentLobbyID==-1 (session has no lobby)"); - } - } - catch (Exception ex) - { - Console.WriteLine($"[WARN] Failed to record in-game abandon for user {user_id}: {ex.Message}"); - } - } - } - - // NOTE: They only went offline if ref count became 0, otherwise they're still online somewhere else - if (sourceData != null && sourceSharedData != null && sourceSharedData.NeedsGC()) - { - // TODO_SOCIAL: Move this to a class - // inform any friends who are online that this person just came online - WebSocketMessage_Social_FriendStatusChanged friendStatusChangedEvent = new(); - friendStatusChangedEvent.msg_id = (int)EWebSocketMessageID.SOCIAL_FRIEND_ONLINE_STATUS_CHANGED; - friendStatusChangedEvent.display_name = sourceSharedData.m_strDisplayName; - friendStatusChangedEvent.online = false; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(friendStatusChangedEvent)); - - if (sourceData != null) - { - // friends are reciprocal so we can just iterate our friends - foreach (Int64 friendID in sourceSharedData.GetSocialContainer().Friends) - { - WebsocketHelper.SendToAllSessionsOfUser(friendID, bytesJSON); - } - } - } - - int numSessions = WebSocketManager.GetNumberOfUsersOnline(); ; - Console.Title = String.Format("GenOnline - {0} players", numSessions); - - try - { - if (oldWS != null) - { - // Close the WS directly, dont rely on session data as it may be linked to something else at this point - await oldWS.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session being deleted"); - } - } - catch - { - - } - } - + foreach (var userData in lstCacheEntriesToDestroy) + await ServiceLocator.Services.GetRequiredService().ExpireSessionAsync(userData.Session); + } + + public static Task DeleteSession(Int64 userID, EUserSessionType sessionType, UserWebSocketInstance? oldWS, bool destroySession, UserSession? expectedSession = null) + { + var session = expectedSession ?? oldWS?.OwnerSession ?? GetSessionFromUser(userID, sessionType); + return ServiceLocator.Services.GetRequiredService().DisconnectSessionAsync(session, oldWS, destroySession); + } + + internal static UserWebSocketInstance? DetachSocket(UserSession session) + { + var sockets = m_dictWebsockets[session.GetSessionType()]; + if (!sockets.TryGetValue(session.m_UserID, out var socket) || !ReferenceEquals(socket.OwnerSession, session)) return null; + return sockets.TryRemove(new KeyValuePair(session.m_UserID, socket)) ? socket : null; + } + + internal static bool RemoveSessionData(UserSession session) + { + if (!m_dictUserSessions[session.GetSessionType()].TryRemove(new KeyValuePair(session.m_UserID, session))) + return false; + if (m_dictSharedUserData.TryGetValue(session.m_UserID, out var shared)) + { + shared.DecrementRefCount(); + if (shared.NeedsGC()) + { + m_dictSharedUserData.TryRemove(new KeyValuePair(session.m_UserID, shared)); + var message = new WebSocketMessage_Social_FriendStatusChanged + { + msg_id = (int)EWebSocketMessageID.SOCIAL_FRIEND_ONLINE_STATUS_CHANGED, + display_name = shared.m_strDisplayName, + online = false + }; + byte[] payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message)); + foreach (long friendID in shared.GetSocialContainer().Friends) + WebsocketHelper.SendToAllSessionsOfUser(friendID, payload); + } + } + Console.Title = $"GenOnline - {GetNumberOfUsersOnline()} players"; + return true; + } + /* public static ChatSession? GetWebSocketForUser(Int64 userID) { @@ -748,7 +644,7 @@ public static async Task DeleteSession(Int64 user_id, EUserSessionType sessionTy // TODO_EFCORE: Just use a weakref to the websocket from the user session instead of lookups public static UserWebSocketInstance? GetWebSocketForSession(UserSession session) { - if (m_dictWebsockets[session.GetSessionType()].TryGetValue(session.m_UserID, out UserWebSocketInstance? retVal)) + if (m_dictWebsockets[session.GetSessionType()].TryGetValue(session.m_UserID, out UserWebSocketInstance? retVal) && ReferenceEquals(retVal.OwnerSession, session)) { return retVal; } @@ -851,7 +747,7 @@ public static async Task DisconnectUser(Int64 userID, byte[] finalMessage) await userSession.FlushWebsocketSendsAsync(TimeSpan.FromSeconds(2)); } - await DeleteSession(userID, userSession.GetSessionType(), oldWS, true); + await DeleteSession(userID, userSession.GetSessionType(), oldWS, true, userSession); } catch (Exception ex) { @@ -861,42 +757,13 @@ public static async Task DisconnectUser(Int64 userID, byte[] finalMessage) } } - public static async Task ClearDataFromUser(Int64 userID, EUserSessionType sessionType) - { - // NOTE: This is when a player is truly disconnected and we can destroy session, remove form lobby etc, websocket disconnect doesnt mean that because the clietn reconnects - try - { - UserSession? userData = null; - - if (m_dictUserSessions[sessionType].ContainsKey(userID)) - { - userData = m_dictUserSessions[sessionType][userID]; - } - await SessionHelpers.FullyDestroyPlayerSession(userID, userData, true); - - // decrement ref count on shared data - if (m_dictSharedUserData.TryGetValue(userID, out SharedUserData? sharedData)) - { - sharedData.DecrementRefCount(); - if (sharedData.NeedsGC()) // cleanup if necessary - { - m_dictSharedUserData.Remove(userID, out var removedSharedData); - } - } - else - { - Console.WriteLine("Error: Could not find shared data for user {0} when deleting session", userID); - } - } - catch - { - - } - - return m_dictUserSessions[sessionType].Remove(userID, out var itemRemoved); - } - - + public static Task ClearDataFromUser(Int64 userID, EUserSessionType sessionType) + { + var session = GetSessionFromUser(userID, sessionType); + return ServiceLocator.Services.GetRequiredService().ClearSessionAsync(session); + } + + // helpers private static readonly object g_dirtyLobbyRoomsLock = new(); private static readonly HashSet g_dirtyLobbyRooms = new(); @@ -1175,6 +1042,10 @@ private static Int64 Get(string key, Int64 defaultValue) public class UserSession { + private int retired; + internal bool IsRetired => Volatile.Read(ref retired) != 0; + internal void Retire() => Interlocked.Exchange(ref retired, 1); + public Int64 m_UserID = -1; public string m_strContinent; @@ -1513,7 +1384,11 @@ public void QueueKeepAlive() } private WebSocket? m_SockInternal = null; - private readonly UserSession m_OwnerSession; + private readonly object transportLock = new(); + private bool closed; + private readonly UserSession m_OwnerSession; + internal UserSession OwnerSession => m_OwnerSession; + internal bool IsOpen => m_SockInternal?.State == WebSocketState.Open; private readonly CancellationTokenSource m_sendLoopStop = new CancellationTokenSource(); private Task m_sendLoop = Task.CompletedTask; @@ -1524,12 +1399,20 @@ public UserWebSocketInstance(EUserSessionType sessionType, Int64 ownerID, UserSe m_OwnerSession = ownerSession; } - public void AttachWebsocket(WebSocket sock) - { - m_SockInternal = sock; - m_sendLoop = Task.Run(RunSendLoop); - } - + public void AttachWebsocket(WebSocket sock) + { + lock (transportLock) + { + if (closed) + { + sock.Abort(); + return; + } + m_SockInternal = sock; + m_sendLoop = Task.Run(RunSendLoop); + } + } + public bool AbortIfOpen() { WebSocket? sock = m_SockInternal; @@ -1598,6 +1481,7 @@ private async Task SendFrameAsync(byte[] buffer) public async Task CloseAsync(WebSocketCloseStatus closeStatus, string? statusDescription) { + lock (transportLock) closed = true; // stop reading the queue first so the next connection's send loop is the only reader m_sendLoopStop.Cancel(); @@ -1647,40 +1531,6 @@ public static bool SessionTypeHasAccessTo(EUserSessionType sessType, ESessionAcc return false; } - public static async Task FullyDestroyPlayerSession(Int64 user_id, UserSession? userData, bool bMigrateLobbyIfPresent) - { - // NOTE: Dont assume userData is valid, use user_id for user id - Console.ForegroundColor = ConsoleColor.Cyan; - Console.WriteLine("FullyDestroyPlayerSession for user {0}", user_id); - Console.ForegroundColor = ConsoleColor.Gray; - - // invalidate any TURN credentials - TURNCredentialManager.DeleteCredentialsForUser(user_id); - - // TODO: Implement single point of presence? gets dicey if multiple logins - // TODO: Dont destroy this, just mark inactive/offline, we use this as a saved credential system - - // session tied to this token (keep other ones attached to user_id, could be other machines) - // TODO_JWT: Remove table fully + set logged out - //await m_Inst.Query("DELETE FROM sessions WHERE user_id={0} AND session_type={1};", user_id, (int)ESessionType.Game); - - // leave any lobby - Console.WriteLine("[Source 2] User {0} Leave Any Lobby", user_id); - - var lobbyManager = ServiceLocator.Services.GetRequiredService(); - await lobbyManager.LeaveAnyLobby(user_id); - - await lobbyManager.CleanupUserLobbiesNotStarted(user_id); - - // remove from any matchmaking - if (userData != null) - { - await MatchmakingManager.DeregisterPlayer(userData); - } - - // TODO: Client needs to handle this... itll start returning 404 - } - public async static Task SetUsedLoggedIn(Int64 userID, KnownClients.EKnownClients clientID, EUserSessionType sessionType) { // TODO_EFCORE: website uses this index as 1 (60hz) to 0 (30hz), update it to use new enum + support new clients, also need to update DB to match @@ -1701,7 +1551,7 @@ public async static Task SetUsedLoggedIn(Int64 userID, KnownClients.EKnownClient Console.ForegroundColor = ConsoleColor.Gray; UserWebSocketInstance? oldWS = GenOnlineService.WebSocketManager.GetWebSocketForSession(sess); - await GenOnlineService.WebSocketManager.DeleteSession(userID, sessionType, oldWS, false); + await GenOnlineService.WebSocketManager.DeleteSession(userID, sessionType, oldWS, false, sess); } } } @@ -2795,7 +2645,8 @@ public static async Task DeleteCredentialsForUser(Int64 userID) try { string strURI = String.Format("https://rtc.live.cloudflare.com/v1/turn/keys/{0}/credentials/{1}/revoke", TurnKey, strTURNUsername); - HttpResponseMessage response = await client.PostAsync(strURI, requestContent); + using HttpResponseMessage response = await client.PostAsync(strURI, requestContent); + response.EnsureSuccessStatusCode(); } catch { @@ -3025,6 +2876,9 @@ public class WebSocketMessage_Social_NewFriendRequest : WebSocketMessage public class WebSocketMessage_FullMeshConnectivityCheckOutcome: WebSocketMessage { + public long mesh_check_id { get; set; } + public int attempt { get; set; } + public string reason { get; set; } = string.Empty; public bool mesh_complete { get; set; } public List missing_connections { get; set; } = new(); } diff --git a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs index 167eb72..65f9323 100644 --- a/GenOnlineService/Controllers/Lobbies/LobbiesController.cs +++ b/GenOnlineService/Controllers/Lobbies/LobbiesController.cs @@ -328,8 +328,6 @@ public async Task Put() if (playerSession != null) { - // cleanup any zombie lobbies - await _lobbyManager.CleanupUserLobbiesNotStarted(user_id); await using var db = await _dbFactory.CreateDbContextAsync(); string strDisplayName = await Database.Users.GetDisplayName(db, user_id); @@ -342,14 +340,9 @@ public async Task Put() result.result = 1; result.lobby_id = newLobbyID; - // TODO: What if this fails? just let them proceed? just means only direct connect people will be able to play - // get some turn credentials - TURNCredentialContainer? turnCredentials = await TURNCredentialManager.CreateCredentialsForUser(user_id); - if (turnCredentials != null) - { - result.turn_username = turnCredentials.m_strUsername; - result.turn_token = turnCredentials.m_strToken; - } + var credentials = await _lobbyManager.CreateLobbyRelayCredentialsAsync(playerSession, newLobbyID); + result.turn_username = credentials.Username; + result.turn_token = credentials.Token; } else { diff --git a/GenOnlineService/Controllers/Lobby/LobbyController.cs b/GenOnlineService/Controllers/Lobby/LobbyController.cs index 336e93b..db7a085 100644 --- a/GenOnlineService/Controllers/Lobby/LobbyController.cs +++ b/GenOnlineService/Controllers/Lobby/LobbyController.cs @@ -211,65 +211,18 @@ public async Task Get(string lobby_id) [Authorize(Roles = "GameClient")] public async Task Delete(Int64 lobbyID) { - RouteHandler_DELETE_Lobby_Result result = new RouteHandler_DELETE_Lobby_Result(); - - using (var reader = new StreamReader(HttpContext.Request.Body)) + var result = new RouteHandler_DELETE_Lobby_Result(); + using var reader = new StreamReader(HttpContext.Request.Body); + await reader.ReadToEndAsync(); + try { - string jsonData = await reader.ReadToEndAsync(); - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - try - { - // TODO: Dont let them join more than 1 lobby - // need a lobby ID - int leavingPersonSlot = -1; - Int64 user_id = TokenHelper.GetUserID(this); - EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) - { - Lobby? lobby = _lobbyManager.GetLobby(lobbyID); - if (lobby != null) - { - foreach (var member in lobby.Members) - { - if (member.SlotState == EPlayerType.SLOT_PLAYER) - { - if (member.UserID == user_id) - { - leavingPersonSlot = member.SlotIndex; - } - } - } - } - - Console.WriteLine("[Source 1] User {0} Leave Any Lobby", user_id); - await _lobbyManager.LeaveAnyLobby(user_id); - - // cleanup TURN credentials - TURNCredentialManager.DeleteCredentialsForUser(user_id); - - // clear our lobby ID - UserSession? sourceData = WebSocketManager.GetSessionFromUser(user_id, sessionType); - - if (sourceData != null) - { - sourceData.UpdateSessionLobbyID(-1); - // NOTE: We dont update the match history match ID here, that is done by the match history service - } - - result.success = true; - } - - } - catch - { - return result; - } + result.success = await _lobbyManager.LeaveAsync(new LeaveLobbyCommand(lobbyID, + TokenHelper.GetUserID(this), TokenHelper.GetSessionType(this))); } - + catch + { + return result; + } return result; } @@ -410,94 +363,57 @@ enum ELobbyUpdatePermissions [Authorize(Roles = "GameClient")] public async Task Post(Int64 lobbyID) { - RouteHandler_POST_Lobby_Result result = new RouteHandler_POST_Lobby_Result(); - - using (var reader = new StreamReader(HttpContext.Request.Body)) - { - string jsonData = await reader.ReadToEndAsync(); - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - try - { - // TODO: Dont let them join more than 1 lobby - - var data = JsonSerializer.Deserialize>(jsonData, options); - - if (data != null - && data.ContainsKey("field") - ) - { - // TODO_NGMP: For all things which modify lobbies, we should check if the player is really in the lobby - // get requesting user - Int64 user_id = TokenHelper.GetUserID(this); - if (user_id != -1) - { - Lobby? lobby = _lobbyManager.GetLobby(lobbyID); - - if (lobby != null) - { - // check the user is in the lobby, otherwise bail - LobbyMember? SourceMember = lobby.GetMemberFromUserID(user_id); - - if (SourceMember == null) - { - Response.StatusCode = (int)HttpStatusCode.Unauthorized; - return result; - } - - if (!TryParseLobbyUpdateField(data["field"].GetInt32(), out ELobbyUpdateField field)) - { - Response.StatusCode = (int)HttpStatusCode.BadRequest; - return result; - } - - // check permissions - ELobbyUpdatePermissions updatePerms = g_dictLobbyUpdatePermissionsTable[field]; - - if (updatePerms == ELobbyUpdatePermissions.LobbyOwner) // check owner - { - if (user_id != lobby.Owner) - { - Response.StatusCode = (int)HttpStatusCode.Unauthorized; - result.success = false; - return result; - } - } - - // Every mutation below touches this lobby's Members, slot state, or ready state, - // so the whole field-update dispatch runs as one atomic operation under the - // per-lobby gate instead of each setter mutating state unguarded. - // A kick can't finish the actual removal in here: RemoveMember acquires this - // same lobby's gate, which is not reentrant, so ApplyLobbyFieldUpdateAsync only - // validates the target and hands the user ID back for PerformKickAsync to - // process once the gate below has been released. - Int64? kickedUserID = await lobby.RunExclusiveAsync(() => ApplyLobbyFieldUpdateAsync(lobby, SourceMember, field, data)); - - if (kickedUserID.HasValue) - { - await PerformKickAsync(lobby, kickedUserID.Value); - } - } - } - - } - - } - catch - { + var result = new RouteHandler_POST_Lobby_Result(); + using var reader = new StreamReader(HttpContext.Request.Body); + string jsonData = await reader.ReadToEndAsync(); + try + { + var data = JsonSerializer.Deserialize>(jsonData); + if (data == null || !data.ContainsKey("field")) return result; + long userID = TokenHelper.GetUserID(this); + if (userID == -1) return result; + var lobby = _lobbyManager.GetLobby(lobbyID); + if (lobby == null) return result; + + if (data["field"].GetInt32() == (int)ELobbyUpdateField.HOST_ACTION_KICK_USER) + { + long? targetID = data.TryGetValue("userid", out var target) && target.ValueKind == JsonValueKind.Number + && target.TryGetInt64(out long parsedTarget) ? parsedTarget : null; + var status = await _lobbyManager.KickAsync(new KickLobbyMemberCommand(lobbyID, userID, targetID)); + if (status is LobbyMembershipStatus.ActorNotMember or LobbyMembershipStatus.ActorNotHost) + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + // Legacy kick responses leave success false, even when removal succeeds. return result; } + + await lobby.RunExclusiveAsync(async () => + { + var sourceMember = lobby.GetMemberFromUserID(userID); + if (sourceMember == null) + { + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + return; + } + if (!TryParseLobbyUpdateField(data["field"].GetInt32(), out ELobbyUpdateField field)) + { + Response.StatusCode = (int)HttpStatusCode.BadRequest; + return; + } + if (g_dictLobbyUpdatePermissionsTable[field] == ELobbyUpdatePermissions.LobbyOwner && userID != lobby.Owner) + { + Response.StatusCode = (int)HttpStatusCode.Unauthorized; + return; + } + await ApplyLobbyFieldUpdateAsync(lobby, sourceMember, field, data); + }); } - + catch + { + return result; + } return result; } - // Rejects any wire value that isn't a real EPlayerType member, so a malformed or - // out-of-range slot_state from HOST_ACTION_SET_SLOT_STATE can't be cast into an enum value - // the rest of the lobby code (SetPlayerSlotState, IsAI, serialization, ...) never expects. internal static bool TryParseSlotState(UInt16 rawValue, out EPlayerType slotState) { slotState = (EPlayerType)rawValue; @@ -505,23 +421,14 @@ internal static bool TryParseSlotState(UInt16 rawValue, out EPlayerType slotStat return Enum.IsDefined(typeof(EPlayerType), slotState) && slotState != EPlayerType.SLOT_PLAYER; } - // Rejects any wire value that isn't a real ELobbyUpdateField member, so an unknown/malformed - // field can't be cast into an enum value that then gets indexed straight into - // g_dictLobbyUpdatePermissionsTable (which previously threw KeyNotFoundException, silently - // swallowed by Post's outer catch, for anything out of range). internal static bool TryParseLobbyUpdateField(int rawValue, out ELobbyUpdateField field) { field = (ELobbyUpdateField)rawValue; return Enum.IsDefined(typeof(ELobbyUpdateField), field); } - // Runs the per-field lobby update dispatch. Must only be called from inside - // lobby.RunExclusiveAsync: every branch here mutates this lobby's Members, slot - // state/fields, or ready state, and previously ran completely unguarded. - // Returns the kicked user's ID when HOST_ACTION_KICK_USER validated a real target, so the - // caller can run the actual removal after releasing the gate; null for every other field - // (including a kick request with no valid target). - private async Task ApplyLobbyFieldUpdateAsync(Lobby lobby, LobbyMember SourceMember, ELobbyUpdateField field, Dictionary data) + // Caller holds the lobby gate. + private async Task ApplyLobbyFieldUpdateAsync(Lobby lobby, LobbyMember SourceMember, ELobbyUpdateField field, Dictionary data) { // reset everyones ready states when anything changes (minus dummy actions) if (field != ELobbyUpdateField.HOST_ACTION_FORCE_START @@ -547,7 +454,7 @@ internal static bool TryParseLobbyUpdateField(int rawValue, out ELobbyUpdateFiel if ((strMap != null && strMap.Length > 255) || (strMapPath != null && strMapPath.Length > 512)) { Response.StatusCode = (int)HttpStatusCode.BadRequest; - return null; + return; } if (strMap != null && strMapPath != null) @@ -630,22 +537,6 @@ internal static bool TryParseLobbyUpdateField(int rawValue, out ELobbyUpdateFiel SourceMember.UpdateHasMap(bHasMap); } } - else if (field == ELobbyUpdateField.HOST_ACTION_KICK_USER) - { - if (data.ContainsKey("userid")) - { - Int64 KickedUserID = data["userid"].GetInt64(); - - // the target must actually be in THIS lobby, otherwise a host could wipe the - // TURN credentials / lobby state of any arbitrary online player. Checked here, - // under the gate, so it can't race a concurrent leave/kick of the same target; - // the actual removal runs after the gate is released (see PerformKickAsync). - if (lobby.GetMemberFromUserID(KickedUserID) != null) - { - return KickedUserID; - } - } - } else if (field == ELobbyUpdateField.HOST_ACTION_SET_SLOT_STATE) { UInt16 slot_index = data["slot_index"].GetUInt16(); @@ -653,7 +544,7 @@ internal static bool TryParseLobbyUpdateField(int rawValue, out ELobbyUpdateFiel if (!TryParseSlotState(data["slot_state"].GetUInt16(), out EPlayerType slot_state)) { Response.StatusCode = (int)HttpStatusCode.BadRequest; - return null; + return; } // human slots are left alone; kicking is how a host removes a player @@ -796,165 +687,44 @@ internal static bool TryParseLobbyUpdateField(int rawValue, out ELobbyUpdateFiel } } - return null; - } - - // Runs the parts of a kick that RemoveMember itself performs (leaving the lobby, which - // acquires this same lobby's gate) plus TURN/session cleanup - all after - // ApplyLobbyFieldUpdateAsync's gate has been released, never from inside it. - private async Task PerformKickAsync(Lobby lobby, Int64 kickedUserID) - { - // TODO: we should communicate the kick to the user... - await _lobbyManager.LeaveSpecificLobby(kickedUserID, lobby.LobbyID); - - // cleanup TURN credentials - TURNCredentialManager.DeleteCredentialsForUser(kickedUserID); - - // clear our lobby ID - UserSession? sourceData = WebSocketManager.GetSessionFromUser(kickedUserID, EUserSessionType.GameClient); // user being kicked must be a game client - - if (sourceData != null) - { - sourceData.UpdateSessionLobbyID(-1); - // NOTE: We dont update the match history match ID here, that is done by the match history service - } - - // we have to manually send to the kicked user... they won't get the dirty lobby update anymore - await lobby.DirtyRetransmitToSingleMember(kickedUserID); + return; } [HttpPut("{lobbyID}")] [Authorize(Roles = "GameClient")] public async Task Put(Int64 lobbyID) { - RouteHandler_PUT_Lobby_Result result = new RouteHandler_PUT_Lobby_Result(); - - using (var reader = new StreamReader(HttpContext.Request.Body)) - { - string jsonData = await reader.ReadToEndAsync(); - - var options = new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }; - - try - { - // TODO: Dont let them join more than 1 lobby - - var data = JsonSerializer.Deserialize>(jsonData, options); - - if (data != null - && data.ContainsKey("preferred_port") - && data.ContainsKey("anticheat_id") - ) - { - - Lobby? lobby = _lobbyManager.GetLobby(lobbyID); - - if (lobby != null) - { - Int64 user_id = TokenHelper.GetUserID(this); - EUserSessionType sessionType = TokenHelper.GetSessionType(this); - if (user_id != -1 && SessionHelpers.SessionTypeHasAccessTo(sessionType, ESessionAccessType.Gameplay)) - { - if (lobby.LobbyType == ELobbyType.QuickMatch && !lobby.MatchedUserIDs.Contains(user_id)) - { - Response.StatusCode = (int)HttpStatusCode.Forbidden; - result.success = false; - return result; - } - - UInt16 userPreferredPort = data["preferred_port"].GetUInt16(); - bool bHasMap = data["has_map"].GetBoolean(); - EKnownAnticheatID anticheatID = (EKnownAnticheatID)data["anticheat_id"].GetInt32(); - - // does the lobby have a password? - bool bLobbyPassworded = lobby.IsPassworded; - bool bUserProvidedPassword = data.ContainsKey("password"); - - // we need a password - if (bLobbyPassworded) - { - if (bUserProvidedPassword) - { - // do the passwords match? - string? strUserProvidedPassword = data["password"].GetString(); - - // if it doesnt match, bail, otherwise just proceed as normal - if (strUserProvidedPassword != lobby.Password) - { - Response.StatusCode = (int)HttpStatusCode.Unauthorized; - result.success = false; - return result; - } - } - else - { - // no password, no access - Response.StatusCode = (int)HttpStatusCode.Unauthorized; - result.success = false; - return result; - } - } - - // must be running same AC - if (anticheatID != lobby.AnticheatID) - { - Response.StatusCode = (int)HttpStatusCode.ExpectationFailed; - result.success = false; - return result; - } - - UserSession? playerSession = WebSocketManager.GetSessionFromUser(user_id, sessionType); - - if (playerSession != null) - { - // leave any lobby - await _lobbyManager.LeaveAnyLobby(user_id); - - await using var db = await _dbFactory.CreateDbContextAsync(); - string strDisplayName = await Database.Users.GetDisplayName(db, user_id); - bool bJoinedSuccessfully = await _lobbyManager.JoinLobby(db, lobby, playerSession, strDisplayName, userPreferredPort, bHasMap); - - result.success = bJoinedSuccessfully; - - if (!bJoinedSuccessfully) // this basically means full, didnt find a slot in correct state - { - Response.StatusCode = (int)HttpStatusCode.NotAcceptable; - } - else - { - // TODO: What if this fails? just let them proceed? just means only direct connect people will be able to play - // get some turn credentials - TURNCredentialContainer? turnCredentials = await TURNCredentialManager.CreateCredentialsForUser(user_id); - if (turnCredentials != null) - { - result.turn_username = turnCredentials.m_strUsername; - result.turn_token = turnCredentials.m_strToken; - } - - Response.StatusCode = (int)HttpStatusCode.OK; - } - } - else - { - Response.StatusCode = (int)HttpStatusCode.Unauthorized; - } - } - - } - } - - } - catch - { - return result; - } - } - - return result; - - } + var result = new RouteHandler_PUT_Lobby_Result(); + using var reader = new StreamReader(HttpContext.Request.Body); + string jsonData = await reader.ReadToEndAsync(); + try + { + var data = JsonSerializer.Deserialize>(jsonData); + if (data == null || !data.ContainsKey("preferred_port") || !data.ContainsKey("anticheat_id")) return result; + var joined = await _lobbyManager.JoinAsync(new JoinLobbyCommand(lobbyID, + TokenHelper.GetUserID(this), TokenHelper.GetSessionType(this), + data["preferred_port"].GetUInt16(), data.TryGetValue("has_map", out var hasMap) && hasMap.GetBoolean(), + (EKnownAnticheatID)data["anticheat_id"].GetInt32(), + data.TryGetValue("password", out var password) ? password.GetString() : null)); + result.success = joined.Status == LobbyMembershipStatus.Success; + result.turn_username = joined.TurnUsername; + result.turn_token = joined.TurnToken; + Response.StatusCode = (int)MapMembershipStatus(joined.Status); + } + catch + { + return result; + } + return result; + } + + private static HttpStatusCode MapMembershipStatus(LobbyMembershipStatus status) => status switch + { + LobbyMembershipStatus.ActorNotMember or LobbyMembershipStatus.ActorNotHost or LobbyMembershipStatus.SessionUnavailable or LobbyMembershipStatus.PasswordMismatch => HttpStatusCode.Unauthorized, + LobbyMembershipStatus.NotMatched => HttpStatusCode.Forbidden, + LobbyMembershipStatus.AnticheatMismatch => HttpStatusCode.ExpectationFailed, + LobbyMembershipStatus.Success or LobbyMembershipStatus.NoChange => HttpStatusCode.OK, + _ => HttpStatusCode.NotAcceptable + }; } } diff --git a/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs b/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs index 1de844d..ee296e7 100644 --- a/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs +++ b/GenOnlineService/Controllers/Matchmaking/MatchmakingController.cs @@ -128,7 +128,7 @@ public async Task Delete() if (playerSession != null) { - await MatchmakingManager.DeregisterPlayer(playerSession); + await MatchmakingManager.DeregisterPlayerAsync(playerSession); } } } diff --git a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs index ce70dfd..4d1a3c2 100644 --- a/GenOnlineService/Controllers/WebSocket/WebSocketController.cs +++ b/GenOnlineService/Controllers/WebSocket/WebSocketController.cs @@ -1015,55 +1015,15 @@ await lobby.RunExclusiveAsync(() => } } } - else if (msgID == EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_HOST_REQUESTS_BEGIN) - { - // Host has requested this, as part of the start game flow - - // must be in a lobby - Lobby? lobbyInfo = null; - if (sourceUserSession.currentLobbyID != -1) - { - // must be lobby owner too - lobbyInfo = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); - - if (lobbyInfo == null || lobbyInfo.Owner != sourceUserSession.m_UserID) - { - return; - } - } - - if (lobbyInfo == null) - { - return; - } - - // lock slots (more people joining when we're already doing connectivity checks won't help the situation) - // Awaited separately from StartFullMeshConnectivityCheck below: each is its own - // gated operation on the lobby, so neither ever nests inside the other's gate use. - await lobbyInfo.CloseOpenSlots(); - - // mark lobby as in progress of full mesh connectivity checks - await lobbyInfo.StartFullMeshConnectivityCheck(); - - // start full mesh connectivity checks - lobbyInfo.SendFullMeshConnectivityCheckRequestToMembers(); - } - else if (msgID == EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_RESPONSE) - { - // process a response from a user - WebSocketMessage_FullMeshConnectivityCheckResponseFromUser? fullMeshMsg = - JsonSerializer.Deserialize(payload, JsonOpts); - - // store response - if (fullMeshMsg != null) - { - Lobby? lobby = _lobbyManager.GetLobby(sourceUserSession.currentLobbyID); - if (lobby != null) - { - await lobby.StoreFullMeshConnectivityResponse(sourceUserSession.m_UserID, fullMeshMsg); - } - } - } + else if (msgID == EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_HOST_REQUESTS_BEGIN) + { + await _lobbyManager.StartMeshCheckAsync(sourceUserSession, sourceWS); + } + else if (msgID == EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_RESPONSE) + { + var response = JsonSerializer.Deserialize(payload, JsonOpts); + if (response != null) await _lobbyManager.StoreMeshResponseAsync(sourceUserSession, sourceWS, response); + } else if (msgID == EWebSocketMessageID.NETWORK_CONNECTION_CLIENT_REQUEST_SIGNALLING) { WebSocketMessage_RequestSignaling? signalingRequest = diff --git a/GenOnlineService/LobbyManager.Membership.cs b/GenOnlineService/LobbyManager.Membership.cs new file mode 100644 index 0000000..245f583 --- /dev/null +++ b/GenOnlineService/LobbyManager.Membership.cs @@ -0,0 +1,177 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using Database; +using Microsoft.EntityFrameworkCore; + +namespace GenOnlineService; + +public enum LobbyMembershipStatus +{ + Success, + NoChange, + ActorNotMember, + ActorNotHost, + NotMatched, + AnticheatMismatch, + SessionUnavailable, + PasswordMismatch, + NotInSetup, + AlreadyMember, + NoOpenSlot, + BlockedByOwner, + FriendshipRequired, + PreferencesUnavailable +} + +public sealed record JoinLobbyCommand(long LobbyID, long UserID, EUserSessionType SessionType, + ushort PreferredPort, bool HasMap, EKnownAnticheatID AnticheatID, string? Password); +public sealed record LeaveLobbyCommand(long LobbyID, long UserID, EUserSessionType SessionType); +public sealed record KickLobbyMemberCommand(long LobbyID, long ActorUserID, long? TargetUserID); +public sealed record JoinLobbyResult(LobbyMembershipStatus Status, string TurnUsername = "", string TurnToken = ""); + +public partial class LobbyManager +{ + // Bounded stripes serialize a user's membership transitions across lobbies. Always + // acquire this before a lobby gate; never acquire another stripe while holding one. + private readonly SemaphoreSlim[] membershipGates = Enumerable.Range(0, 256).Select(_ => new SemaphoreSlim(1, 1)).ToArray(); + + private async Task AcquireMembershipGateAsync(long userID) + { + var gate = membershipGates[(int)((ulong)userID % (uint)membershipGates.Length)]; + await gate.WaitAsync(); + return new MembershipLease(gate); + } + + private sealed class MembershipLease(SemaphoreSlim gate) : IDisposable + { + public void Dispose() => gate.Release(); + } + + private readonly IDbContextFactory membershipDbFactory; + private readonly LobbyMembershipConnections membershipConnections; + + public async Task JoinAsync(JoinLobbyCommand command) + { + using var membership = await AcquireMembershipGateAsync(command.UserID); + var lobby = GetLobby(command.LobbyID); + if (lobby == null) return new(LobbyMembershipStatus.NoChange); + if (command.UserID == -1 || !SessionHelpers.SessionTypeHasAccessTo(command.SessionType, ESessionAccessType.Gameplay)) + return new(LobbyMembershipStatus.NoChange); + + var session = membershipConnections.GetSession(command.UserID, command.SessionType); + if (session == null || session.IsRetired) return new(LobbyMembershipStatus.SessionUnavailable); + + await using var db = await membershipDbFactory.CreateDbContextAsync(); + var displayName = await Users.GetDisplayName(db, command.UserID); + var preferences = await Users.GetUserLobbyPreferences(db, command.UserID); + if (preferences == null) return new(LobbyMembershipStatus.PreferencesUnavailable); + var sources = m_dictLobbies.Values.Where(source => source.GetMemberFromUserID(command.UserID) != null).ToArray(); + var status = await Lobby.TransferMemberAsync(lobby, sources, command, session, displayName, preferences); + if (status != LobbyMembershipStatus.Success) return new(status); + + var credentials = await membershipConnections.CreateRelayCredentialsAsync(command.UserID); + return new(status, credentials.Username, credentials.Token); + } + + public async Task LeaveAsync(LeaveLobbyCommand command) + { + if (command.UserID == -1 || !SessionHelpers.SessionTypeHasAccessTo(command.SessionType, ESessionAccessType.Gameplay)) + return false; + using var membership = await AcquireMembershipGateAsync(command.UserID); + var lobby = GetLobby(command.LobbyID); + if (lobby == null) return false; + return await RemoveMembershipAsync(lobby, command.UserID) != null; + } + + public async Task KickAsync(KickLobbyMemberCommand command) + { + using var membership = await AcquireMembershipGateAsync(command.TargetUserID ?? command.ActorUserID); + var lobby = GetLobby(command.LobbyID); + if (lobby == null) return LobbyMembershipStatus.NoChange; + var status = await lobby.KickMemberAsync(command); + if (status == LobbyMembershipStatus.Success && command.TargetUserID is long targetID) + { + await ClearSessionMembershipStateAsync(targetID, EUserSessionType.GameClient); + // Removed members no longer receive ordinary dirty lobby updates. + await lobby.DirtyRetransmitToSingleMember(targetID); + } + return status; + } + + private async Task ClearSessionMembershipStateAsync(long userID, EUserSessionType sessionType) + { + try + { + await membershipConnections.DeleteRelayCredentialsAsync(userID); + } + finally + { + membershipConnections.GetSession(userID, sessionType)?.UpdateSessionLobbyID(-1); + } + // Match-history membership is owned by the match-history service. + } + + internal async Task RemoveMatchmakingMemberAsync(Lobby lobby, LobbyMember member, bool sendPeerTeardown = false) + { + using var membership = await AcquireMembershipGateAsync(member.UserID); + if (!member.GetSession().TryGetTarget(out var session) || !IsCurrentSession(session) || session.IsRetired) return; + if (sendPeerTeardown) lobby.SendPeerTeardownToDepartingMember(member); + await RemoveMembershipAsync(lobby, member.UserID, member); + } + + // Caller holds the user's membership gate through removal and session cleanup. + private async Task RemoveMembershipAsync(Lobby lobby, long userID, LobbyMember? expectedMember = null) + { + var removed = await lobby.RemoveMemberAsync(userID, expectedMember); + if (removed != null && removed.GetSession().TryGetTarget(out var session) + && IsCurrentSession(session) && !session.IsRetired && session.currentLobbyID == lobby.LobbyID) + await ClearSessionMembershipStateAsync(userID, session.GetSessionType()); + return removed; + } + + internal async Task TryRequeueMatchmakingAsync(UserSession session, long expectedLobbyID, byte[] requeueActionJSON) + { + using var membership = await AcquireMembershipGateAsync(session.m_UserID); + if (!IsCurrentSession(session) || session.IsRetired + || (session.currentLobbyID != -1 && session.currentLobbyID != expectedLobbyID)) return false; + if (session.currentLobbyID == expectedLobbyID && expectedLobbyID != -1) + await ClearSessionMembershipStateAsync(session.m_UserID, session.GetSessionType()); + return await MatchmakingManager.TryRequeueRegisteredPlayer(session, requeueActionJSON); + } + + public async Task LeaveAllLobbiesAsync(long userID) + { + using var membership = await AcquireMembershipGateAsync(userID); + await LeaveMembershipsAsync(userID); + } + + private async Task LeaveMembershipsAsync(long userID) + { + foreach (Lobby lobby in m_dictLobbies.Values) + await RemoveMembershipAsync(lobby, userID); + } + internal async Task<(string Username, string Token)> CreateLobbyRelayCredentialsAsync(UserSession session, long lobbyID) + { + using var membership = await AcquireMembershipGateAsync(session.m_UserID); + if (!IsCurrentSession(session) || session.IsRetired || session.currentLobbyID != lobbyID) + return ("", ""); + return await membershipConnections.CreateRelayCredentialsAsync(session.m_UserID); + } + +} diff --git a/GenOnlineService/LobbyManager.Sessions.cs b/GenOnlineService/LobbyManager.Sessions.cs new file mode 100644 index 0000000..492aaa4 --- /dev/null +++ b/GenOnlineService/LobbyManager.Sessions.cs @@ -0,0 +1,152 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +using Database; +using System.Net.WebSockets; + +namespace GenOnlineService; + +public partial class LobbyManager +{ + internal async Task OpenSessionAsync(AppDbContext db, EUserSessionType sessionType, bool reconnect, + long userID, KnownClients.EKnownClients clientID, string ipAddress, string continent, string country, + double latitude, double longitude, bool admin) + { + using var membership = await AcquireMembershipGateAsync(userID); + var previous = membershipConnections.GetSession(userID, sessionType); + if (!reconnect && previous != null) await DestroySessionAsync(previous); + if (reconnect && (previous == null || previous.IsRetired)) return null; + var installed = await WebSocketManager.InstallSession(db, sessionType, reconnect, userID, clientID, + ipAddress, continent, country, latitude, longitude, admin); + if (reconnect && previous != null) await InvalidateSessionMeshAsync(previous); + return installed; + } + + internal async Task DisconnectSessionAsync(UserSession? session, UserWebSocketInstance? socket, bool destroySession) + { + if (session == null) return; + using (await AcquireMembershipGateAsync(session.m_UserID)) + { + if (IsCurrentSession(session) && ReferenceEquals(WebSocketManager.GetWebSocketForSession(session), socket)) + { + if (destroySession) await DestroySessionAsync(session); + else + { + WebSocketManager.DetachSocket(session); + await InvalidateSessionMeshAsync(session); + session.MarkAbandoned(); + var lobby = GetLobby(session.currentLobbyID); + if (lobby?.State == ELobbyState.INGAME) lobby.RecordPlayerIngameAbandon(session.m_UserID); + } + } + if (socket != null) await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session being deleted"); + } + } + + internal Task ClearSessionAsync(UserSession? session) => RemoveSessionAsync(session, expiredOnly: false); + internal Task ExpireSessionAsync(UserSession session) => RemoveSessionAsync(session, expiredOnly: true); + + private async Task RemoveSessionAsync(UserSession? session, bool expiredOnly) + { + if (session == null) return false; + using var membership = await AcquireMembershipGateAsync(session.m_UserID); + if (!IsCurrentSession(session)) return false; + if (expiredOnly && !WebSocketManager.ShouldStillClearAbandonedSession(session, session)) return false; + await DestroySessionAsync(session); + return true; + } + + private bool IsCurrentSession(UserSession session) => + ReferenceEquals(membershipConnections.GetSession(session.m_UserID, session.GetSessionType()), session); + + private async Task DestroySessionAsync(UserSession session) + { + session.Retire(); + bool gameplay = SessionHelpers.SessionTypeHasAccessTo(session.GetSessionType(), ESessionAccessType.Gameplay); + try + { + if (gameplay) + { + await InvalidateSessionMeshAsync(session); + await MatchmakingManager.DetachSessionAsync(session); + await LeaveMembershipsAsync(session.m_UserID); + await CleanupUserLobbiesNotStartedAsync(session.m_UserID); + } + } + catch (Exception ex) + { + SentrySdk.CaptureException(ex); + throw; + } + finally + { + try + { + if (gameplay) await membershipConnections.DeleteRelayCredentialsAsync(session.m_UserID); + } + finally + { + var socket = WebSocketManager.DetachSocket(session); + try + { + WebSocketManager.RemoveSessionData(session); + } + finally + { + // Send loops never invoke lifecycle operations. Stop the old queue reader + // before allowing a reconnect to attach another reader to the same session. + if (socket != null) await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session replaced or expired"); + } + } + } + } + + private async Task InvalidateSessionMeshAsync(UserSession session) + { + if (!SessionHelpers.SessionTypeHasAccessTo(session.GetSessionType(), ESessionAccessType.Gameplay)) return; + var lobby = GetLobby(session.currentLobbyID); + if (lobby != null) await lobby.InvalidateMeshForSessionAsync(session); + } + + internal async Task StartMeshCheckAsync(UserSession session, UserWebSocketInstance socket) + { + using var membership = await AcquireMembershipGateAsync(session.m_UserID); + if (!IsCurrentSession(session) || session.IsRetired || !ReferenceEquals(WebSocketManager.GetWebSocketForSession(session), socket)) return; + var lobby = GetLobby(session.currentLobbyID); + if (lobby != null) await lobby.StartMeshCheckAsync(session, socket); + } + + internal async Task StoreMeshResponseAsync(UserSession session, UserWebSocketInstance socket, + WebSocketMessage_FullMeshConnectivityCheckResponseFromUser response) + { + using var membership = await AcquireMembershipGateAsync(session.m_UserID); + if (!IsCurrentSession(session) || session.IsRetired || !ReferenceEquals(WebSocketManager.GetWebSocketForSession(session), socket)) return; + var lobby = GetLobby(session.currentLobbyID); + if (lobby != null) await lobby.StoreMeshResponseAsync(session, socket, response); + } + + internal async Task CancelMatchmakingAsync(UserSession session) + { + using var membership = await AcquireMembershipGateAsync(session.m_UserID); + if (!IsCurrentSession(session) || session.IsRetired) return; + var detached = await MatchmakingManager.DetachSessionAsync(session); + foreach (var entry in detached.Members) + await RemoveMembershipAsync(entry.Lobby, session.m_UserID, entry.Member); + if (!detached.CancellationRejected) await LeaveMembershipsAsync(session.m_UserID); + } +} diff --git a/GenOnlineService/LobbyManager.cs b/GenOnlineService/LobbyManager.cs index 5a677d7..ea658da 100644 --- a/GenOnlineService/LobbyManager.cs +++ b/GenOnlineService/LobbyManager.cs @@ -66,18 +66,18 @@ internal static bool ShouldRetry(bool meshComplete, bool hasLegacyResponse, int internal static class FullMeshCheckSettings { // how long an attempt waits for every connection before judging it - internal static int AttemptWindowMS => Get("full_mesh_check_attempt_window_ms", 8000); + internal static int AttemptWindowMs => Get("full_mesh_check_attempt_window_ms", 8000); // how often an incomplete attempt asks members for a fresh snapshot - internal static int SnapshotIntervalMS => Get("full_mesh_check_snapshot_interval_ms", 1000); + internal static int SnapshotIntervalMs => Get("full_mesh_check_snapshot_interval_ms", 1000); // time given to re-signalled connections before the next attempt - internal static int RetryDelayMS => Get("full_mesh_check_retry_delay_ms", 3000); + internal static int RetryDelayMs => Get("full_mesh_check_retry_delay_ms", 3000); internal static int MaxAttempts => Get("full_mesh_check_max_attempts", 2); // upper bound for a whole check, used as the clients' setup timeout - internal static int MaxDurationMS => (AttemptWindowMS * MaxAttempts) + (RetryDelayMS * (MaxAttempts - 1)); + internal static int MaxDurationMs => (AttemptWindowMs * MaxAttempts) + (RetryDelayMs * (MaxAttempts - 1)); private static int Get(string key, int defaultValue) { @@ -111,7 +111,7 @@ public class Lobby // SemaphoreSlim, so there is exactly one lock to reason about and no lock-ordering hazard // between the two. It is NOT reentrant: a callback passed to RunExclusiveAsync must never // call back into RunExclusiveAsync on this same lobby, or it will deadlock - callers that are - // already inside a callback (e.g. DoHostMigration, called from RemoveMember's callback) must + // already inside a callback (e.g. DoHostMigration, called from RemoveMemberAsync's callback) must // mutate state directly instead. Sending a websocket message from inside a callback is safe: // QueueWebsocketSend only enqueues bytes onto an in-memory bounded channel (a non-blocking // TryWrite) - it never performs real network I/O itself, so it cannot block while the gate is @@ -262,19 +262,108 @@ public async Task RegisterProbeResponse_Malformed_Type2(Int64 userID) } // End AC Probes - public async Task StartFullMeshConnectivityCheck() + private sealed record MeshParticipant(LobbyMember Member, UserSession Session, UserWebSocketInstance? Socket); + private Dictionary m_MeshParticipants = new(); + private MeshParticipant? m_MeshHost; + private long m_MembershipVersion; + private long m_MembershipVersionAtMeshCheckStart; + [JsonIgnore] + public string? LastFullMeshCheckFailureReason { get; private set; } + + public Task StartMeshCheckAsync(UserSession? requester = null, UserWebSocketInstance? socket = null) { - await RunExclusiveAsync(() => + return RunExclusiveAsync(() => { + var host = GetMemberFromUserID(Owner); + if (State != ELobbyState.GAME_SETUP || host == null || !host.GetSession().TryGetTarget(out var hostSession) + || (requester != null && (!ReferenceEquals(hostSession, requester) + || !ReferenceEquals(WebSocketManager.GetWebSocketForSession(requester), socket)))) + return Task.FromResult(0L); + + if (requester != null) + { + foreach (var member in Members) + if (member.SlotState == EPlayerType.SLOT_OPEN) member.SetPlayerSlotState(EPlayerType.SLOT_CLOSED); + DirtyRetransmit(); + } FullMeshCheckID = Interlocked.Increment(ref s_NextFullMeshCheckID); FullMeshCheckAttempt = 1; + m_MembershipVersionAtMeshCheckStart = m_MembershipVersion; + m_MeshParticipants = new(); + foreach (var member in Members.Where(member => member.IsHuman())) + if (member.GetSession().TryGetTarget(out var session)) + m_MeshParticipants[member.UserID] = new(member, session, WebSocketManager.GetWebSocketForSession(session)); + m_MeshHost = m_MeshParticipants.GetValueOrDefault(Owner); m_TimeToRetryFullMeshChecks = -1; LastFullMeshConnectivityCheckOutcome = null; + LastFullMeshCheckFailureReason = null; BeginFullMeshConnectivityCheckAttempt(); - return Task.CompletedTask; + if (EnsureMeshCheckIsCurrentLocked()) SendFullMeshConnectivityCheckRequestToMembers(); + return Task.FromResult(FullMeshCheckID); }); } + private bool IsCurrentMeshParticipant(MeshParticipant participant) => + ReferenceEquals(GetMemberFromUserID(participant.Session.m_UserID), participant.Member) + && participant.Member.GetSession().TryGetTarget(out var session) && ReferenceEquals(session, participant.Session) + && !session.IsRetired && participant.Socket?.IsOpen == true + && ReferenceEquals(WebSocketManager.GetWebSocketForSession(session), participant.Socket); + + private bool EnsureMeshCheckIsCurrentLocked() + { + if (FullMeshCheckID == 0) return false; + string? reason = State != ELobbyState.GAME_SETUP ? "lobby_state_changed" + : m_MembershipVersion != m_MembershipVersionAtMeshCheckStart + || m_MeshParticipants.Count != GetNumberOfHumans() ? "membership_changed" + : m_MeshParticipants.Values.Any(participant => !IsCurrentMeshParticipant(participant)) ? "session_changed" + : m_MeshHost == null || Owner != m_MeshHost.Session.m_UserID ? "host_changed" : null; + if (reason == null) return true; + InvalidateMeshCheck(reason); + return false; + } + + private void InvalidateMeshCheck(string reason) + { + if (PendingFullMeshConnectivityChecks) CompleteMeshCheck(false, reason, new()); + else if (FullMeshCheckID != 0) + { + LastFullMeshConnectivityCheckOutcome = false; + LastFullMeshCheckFailureReason = reason; + } + } + + internal Task InvalidateMeshForSessionAsync(UserSession session) => RunExclusiveAsync(() => + { + if (m_MeshParticipants.Values.Any(participant => ReferenceEquals(participant.Session, session))) + InvalidateMeshCheck("session_changed"); + return Task.CompletedTask; + }); + + public Task GetValidatedMeshCheckResultAsync(long checkID) => RunExclusiveAsync(() => + { + EnsureMeshCheckIsCurrentLocked(); + return Task.FromResult(checkID == FullMeshCheckID && checkID != 0 + ? LastFullMeshConnectivityCheckOutcome : (bool?)false); + }); + + private void CompleteMeshCheck(bool success, string? reason, List missing) + { + if (!PendingFullMeshConnectivityChecks) return; + PendingFullMeshConnectivityChecks = false; + TimeStartFullMeshChecks = -1; + m_TimeToRetryFullMeshChecks = -1; + LastFullMeshConnectivityCheckOutcome = success; + LastFullMeshCheckFailureReason = reason; + if (m_MeshHost == null || Owner != m_MeshHost.Session.m_UserID || !IsCurrentMeshParticipant(m_MeshHost)) return; + var outcome = new WebSocketMessage_FullMeshConnectivityCheckOutcome + { + msg_id = (int)EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST, + mesh_check_id = FullMeshCheckID, attempt = FullMeshCheckAttempt, + mesh_complete = success, reason = reason ?? string.Empty, missing_connections = missing + }; + m_MeshHost.Session.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outcome))); + } + private void BeginFullMeshConnectivityCheckAttempt() { PendingFullMeshConnectivityChecks = true; @@ -282,7 +371,7 @@ private void BeginFullMeshConnectivityCheckAttempt() m_FullMeshConnecting = new(); m_bCurrentAttemptHasLegacyResponse = false; TimeStartFullMeshChecks = Environment.TickCount64; - m_TimeNextFullMeshSnapshotRequest = TimeStartFullMeshChecks + FullMeshCheckSettings.SnapshotIntervalMS; + m_TimeNextFullMeshSnapshotRequest = TimeStartFullMeshChecks + FullMeshCheckSettings.SnapshotIntervalMs; } private bool IsFullMeshPairStillConnecting(Int64 userA, Int64 userB) @@ -311,7 +400,7 @@ private void LogFullMeshCheckAttempt(bool bMeshComplete, List 0 ? $" (still connecting {strConnecting})" : "")}"); } - public void SendFullMeshConnectivityCheckRequestToMembers() + private void SendFullMeshConnectivityCheckRequestToMembers() { WebSocketMessage_FullMeshConnectivityCheckRequest startCommand = new WebSocketMessage_FullMeshConnectivityCheckRequest(); startCommand.msg_id = (int)EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_RESPONSE; @@ -373,31 +462,21 @@ private void SendStartSignallingToMember(LobbyMember recipient, LobbyMember peer } } - public async Task StoreFullMeshConnectivityResponse(Int64 sourceUser, WebSocketMessage_FullMeshConnectivityCheckResponseFromUser response) + public Task StoreMeshResponseAsync(UserSession source, UserWebSocketInstance socket, + WebSocketMessage_FullMeshConnectivityCheckResponseFromUser response) => RunExclusiveAsync(() => { - await RunExclusiveAsync(() => - { - bool bLegacyResponse = FullMeshCheckProtocol.IsLegacyResponse(response); - bool bMatchesCurrentAttempt = FullMeshCheckProtocol.MatchesCurrentAttempt( - response, - FullMeshCheckID, - FullMeshCheckAttempt); - - LobbyMember? sourceMember = GetMemberFromUserID(sourceUser); - if (PendingFullMeshConnectivityChecks - && m_TimeToRetryFullMeshChecks == -1 - && sourceMember?.IsHuman() == true - && bMatchesCurrentAttempt) - { - m_bCurrentAttemptHasLegacyResponse |= bLegacyResponse; - FullMeshConnectivityChecks[sourceUser] = new ConcurrentList(response.connectivity_map); - m_FullMeshConnecting[sourceUser] = new HashSet(response.connecting_map); - } - - ProcessPendingFullMeshConnectivityChecksInternal(); + // Legacy zero IDs cannot distinguish successive checks on the same socket. + if (!EnsureMeshCheckIsCurrentLocked() || !PendingFullMeshConnectivityChecks || m_TimeToRetryFullMeshChecks != -1 + || !m_MeshParticipants.TryGetValue(source.m_UserID, out var participant) + || !ReferenceEquals(participant.Session, source) || !ReferenceEquals(participant.Socket, socket) + || !FullMeshCheckProtocol.MatchesCurrentAttempt(response, FullMeshCheckID, FullMeshCheckAttempt)) return Task.CompletedTask; - }); - } + m_bCurrentAttemptHasLegacyResponse |= FullMeshCheckProtocol.IsLegacyResponse(response); + FullMeshConnectivityChecks[source.m_UserID] = new(response.connectivity_map ?? new()); + m_FullMeshConnecting[source.m_UserID] = new(response.connecting_map ?? new()); + ProcessPendingFullMeshConnectivityChecksInternal(); + return Task.CompletedTask; + }); public async Task ProcessPendingFullMeshConnectivityChecks() { @@ -411,172 +490,43 @@ await RunExclusiveAsync(() => // Must only be called while holding m_LobbyGate (via RunExclusiveAsync). private void ProcessPendingFullMeshConnectivityChecksInternal() { - if (!PendingFullMeshConnectivityChecks) - { - return; - } - - // Give re-signalled connections time to establish before starting the retry. + if (!EnsureMeshCheckIsCurrentLocked() || !PendingFullMeshConnectivityChecks) return; if (m_TimeToRetryFullMeshChecks != -1) { - if (Environment.TickCount64 < m_TimeToRetryFullMeshChecks) - { - return; - } - + if (Environment.TickCount64 < m_TimeToRetryFullMeshChecks) return; m_TimeToRetryFullMeshChecks = -1; BeginFullMeshConnectivityCheckAttempt(); SendFullMeshConnectivityCheckRequestToMembers(); return; } - { - bool bDoneChecks = false; - - // judged as soon as anyone replies: members who haven't replied yet count as missing, and gaps before - // the window closes are re-polled below, which also recovers replies lost to a reconnect - bool bWindowElapsed = (Environment.TickCount64 - TimeStartFullMeshChecks) >= FullMeshCheckSettings.AttemptWindowMS; - bDoneChecks = bWindowElapsed || !FullMeshConnectivityChecks.IsEmpty; - - List lstMissingConnections = new(); - - if (bDoneChecks) + var missing = new List(); + foreach (var source in m_MeshParticipants.Keys) + foreach (var target in m_MeshParticipants.Keys) + if (source != target && (!FullMeshConnectivityChecks.TryGetValue(source, out var connected) || !connected.Contains(target))) + missing.Add(new() { source_user_id = source, target_user_id = target }); + bool disabled = Program.g_Config?.GetSection("Core").GetValue("disable_full_mesh_check") == true; + bool allReported = m_MeshParticipants.Keys.All(FullMeshConnectivityChecks.ContainsKey); + bool complete = disabled || (allReported && missing.Count == 0); + bool attemptTimedOut = Environment.TickCount64 - TimeStartFullMeshChecks >= FullMeshCheckSettings.AttemptWindowMs; + if (!complete && !attemptTimedOut) + { + if (Environment.TickCount64 >= m_TimeNextFullMeshSnapshotRequest) { - // now verify each user has provided data for all other users - foreach (var userMap in FullMeshConnectivityChecks) - { - // a member who left mid-check no longer counts - if (GetMemberFromUserID(userMap.Key)?.IsHuman() != true) - { - continue; - } - - // foreach member in the lobby, check they are in userMap.Value - foreach (LobbyMember member in Members) - { - if (member.IsHuman()) - { - // useful for test -// if (member.UserID != userMap.Key && member.UserID == 1) -// { -// // register it -// MissingConnectionEntry missingConnectionEntry = new(); -// missingConnectionEntry.source_user_id = userMap.Key; -// missingConnectionEntry.target_user_id = member.UserID; -// lstMissingConnections.Add(missingConnectionEntry); -// } - - // wont have a connection to ourself - if (member.UserID != userMap.Key && !userMap.Value.Contains(member.UserID)) - { - // register it - MissingConnectionEntry missingConnectionEntry = new(); - missingConnectionEntry.source_user_id = userMap.Key; - missingConnectionEntry.target_user_id = member.UserID; - lstMissingConnections.Add(missingConnectionEntry); - } - } - } - } - - // A member that never reported cannot be assumed connected, so treat them as missing to everyone. - foreach (LobbyMember member in Members) - { - if (member.IsHuman() && !FullMeshConnectivityChecks.ContainsKey(member.UserID)) - { - foreach (LobbyMember otherMember in Members) - { - if (otherMember.IsHuman() && otherMember.UserID != member.UserID) - { - MissingConnectionEntry missingConnectionEntry = new(); - missingConnectionEntry.source_user_id = member.UserID; - missingConnectionEntry.target_user_id = otherMember.UserID; - lstMissingConnections.Add(missingConnectionEntry); - } - } - } - } - - bool bDisableMeshCheck = false; - if (Program.g_Config != null) - { - IConfiguration? coreSettings = Program.g_Config.GetSection("Core"); - - if (coreSettings != null) - { - bDisableMeshCheck = coreSettings.GetValue("disable_full_mesh_check"); - } - } - - - - bool bMeshComplete = bDisableMeshCheck || lstMissingConnections.Count == 0; - - // members report a snapshot, so a gap before the window closes may be a connection still forming: - // keep asking for fresh snapshots rather than re-signalling it early - if (!bMeshComplete && !bWindowElapsed) - { - if (Environment.TickCount64 >= m_TimeNextFullMeshSnapshotRequest) - { - m_TimeNextFullMeshSnapshotRequest = Environment.TickCount64 + FullMeshCheckSettings.SnapshotIntervalMS; - SendFullMeshConnectivityCheckRequestToMembers(); - } - - return; - } - - LogFullMeshCheckAttempt(bMeshComplete, lstMissingConnections); - - if (FullMeshCheckProtocol.ShouldRetry( - bMeshComplete, - m_bCurrentAttemptHasLegacyResponse, - FullMeshCheckAttempt, - FullMeshCheckSettings.MaxAttempts)) - { - ++FullMeshCheckAttempt; - - // a pair still negotiating would be torn down by a re-signal; the next attempt re-checks it - RestartSignallingForMissingConnections(lstMissingConnections - .Where(c => !IsFullMeshPairStillConnecting(c.source_user_id, c.target_user_id)) - .ToList()); - m_TimeToRetryFullMeshChecks = Environment.TickCount64 + FullMeshCheckSettings.RetryDelayMS; - return; - } - - // inform host that we are done - // start full mesh connectivity checks - WebSocketMessage_FullMeshConnectivityCheckOutcome outcome = new WebSocketMessage_FullMeshConnectivityCheckOutcome(); - outcome.msg_id = (int)EWebSocketMessageID.FULL_MESH_CONNECTIVITY_CHECK_RESPONSE_COMPLETE_TO_HOST; - - if (bDisableMeshCheck) - { - outcome.mesh_complete = true; - outcome.missing_connections = new List(); - } - else - { - outcome.mesh_complete = lstMissingConnections.Count == 0; - outcome.missing_connections = lstMissingConnections; - } - - LastFullMeshConnectivityCheckOutcome = outcome.mesh_complete; - - // TODO_EFCORE: Later, these should really use lobby list instead of getting session from ID - - // send to host - UserSession? hostSession = WebSocketManager.GetSessionFromUser(Owner, EUserSessionType.GameClient); // host should be a game client - if (hostSession != null) - { - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(outcome)); - hostSession.QueueWebsocketSend(bytesJSON); - } - - // reset state - PendingFullMeshConnectivityChecks = false; - TimeStartFullMeshChecks = -1; - m_TimeToRetryFullMeshChecks = -1; + m_TimeNextFullMeshSnapshotRequest = Environment.TickCount64 + FullMeshCheckSettings.SnapshotIntervalMs; + SendFullMeshConnectivityCheckRequestToMembers(); } + return; } + LogFullMeshCheckAttempt(complete, missing); + if (FullMeshCheckProtocol.ShouldRetry(complete, m_bCurrentAttemptHasLegacyResponse, FullMeshCheckAttempt, FullMeshCheckSettings.MaxAttempts)) + { + ++FullMeshCheckAttempt; + RestartSignallingForMissingConnections(missing.Where(connection => !IsFullMeshPairStillConnecting(connection.source_user_id, connection.target_user_id)).ToList()); + m_TimeToRetryFullMeshChecks = Environment.TickCount64 + FullMeshCheckSettings.RetryDelayMs; + return; + } + CompleteMeshCheck(complete, complete ? null : allReported ? "missing_connections" : "timeout", disabled ? new() : missing); } public void AddPassword(string password) @@ -720,8 +670,8 @@ public int MaxPlayers public ELobbyJoinability LobbyJoinability { get; private set; } = ELobbyJoinability.Public; // public by default - public const int maxLobbySize = 8; - public LobbyMember[] Members { get; private set; } = new LobbyMember[maxLobbySize]; + public const int MaxLobbySize = 8; + public LobbyMember[] Members { get; private set; } = new LobbyMember[MaxLobbySize]; public EKnownAnticheatID AnticheatID { get; private set; } = EKnownAnticheatID.NONE; @@ -847,11 +797,11 @@ public Lobby(Int64 lobby_id, UserSession owner, string name, ELobbyState state, RNGSeed = rng_seed; MaximumCameraHeight = max_cam_height; LobbyType = lobbyType; - Members = new LobbyMember[maxLobbySize]; + Members = new LobbyMember[MaxLobbySize]; AnticheatID = inAnticheatID; // create default slots - for (UInt16 i = 0; i < maxLobbySize; ++i) + for (UInt16 i = 0; i < MaxLobbySize; ++i) { LobbyMember placeholderMember = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, i < max_players ? EPlayerType.SLOT_OPEN : EPlayerType.SLOT_CLOSED, i, true); Members[i] = placeholderMember; @@ -861,8 +811,8 @@ public Lobby(Int64 lobby_id, UserSession owner, string name, ELobbyState state, public event Action? OnLobbyNeedsDestroyed; - // Must only be called while holding m_LobbyGate (from within RemoveMember's callback): it reads - // and mutates Members/Owner and must observe RemoveMember's slot clear atomically with any + // Must only be called while holding m_LobbyGate (from within RemoveMemberAsync's callback): it reads + // and mutates Members/Owner and must observe RemoveMemberAsync's slot clear atomically with any // concurrent join/leave/migration. private void OnAfterPlayerLeftLocked(Int64 leavingUserID) { @@ -934,9 +884,9 @@ public async Task FinalizeACChecks() } // Runs under the per-lobby gate: this mutates slot state and previously ran unguarded, - // racing against AddMember/RemoveMember/other slot updates. Sequential with (never nested - // inside) StartFullMeshConnectivityCheck's own gate use - callers await this first and then - // await StartFullMeshConnectivityCheck as a separate gated operation, so the gate is never + // racing against AddMember/RemoveMemberAsync/other slot updates. Sequential with (never nested + // inside) StartMeshCheckAsync's own gate use - callers await this first and then + // await StartMeshCheckAsync as a separate gated operation, so the gate is never // re-entered. public async Task CloseOpenSlots() { @@ -956,7 +906,7 @@ await RunExclusiveAsync(() => } // Must only be called while holding m_LobbyGate (currently only true from - // OnAfterPlayerLeftLocked, itself only called from within RemoveMember's callback). + // OnAfterPlayerLeftLocked, itself only called from within RemoveMemberAsync's callback). private void DoHostMigration() { Int64 oldOwner = Owner; @@ -972,8 +922,10 @@ private void DoHostMigration() Owner = member.UserID; member.UpdateSlotIndex(0); - Members[0] = member; - Members[oldSlot] = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, EPlayerType.SLOT_OPEN, oldSlot, true); + var migrated = (LobbyMember[])Members.Clone(); + migrated[0] = member; + migrated[oldSlot] = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, EPlayerType.SLOT_OPEN, oldSlot, true); + Members = migrated; member.SetReadyState(true); DirtyRetransmitLobbyList(); @@ -1084,73 +1036,81 @@ public async Task Tick() } } - public async Task AddMember(UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap, UserLobbyPreferences lobbyPrefs) + internal static async Task TransferMemberAsync(Lobby destination, IReadOnlyCollection sources, + JoinLobbyCommand command, UserSession session, string displayName, UserLobbyPreferences preferences) + { + var locked = sources.Append(destination).Distinct().OrderBy(lobby => lobby.LobbyID).ToArray(); + int acquired = 0; + try + { + // The caller holds the user's membership gate. All multi-lobby operations use + // ascending IDs; only synchronous state changes and queue writes follow. + foreach (var lobby in locked) + { + await lobby.m_LobbyGate.WaitAsync(); + ++acquired; + } + var admission = destination.ValidateJoin(command); + if (admission != LobbyMembershipStatus.Success) return admission; + var added = destination.AddMemberLocked(session, displayName, command.PreferredPort, command.HasMap, preferences); + if (added.Member == null) return added.Status; + // Destination admission and insertion must succeed before releasing old membership. + foreach (var source in sources) + { + if (ReferenceEquals(source, destination)) continue; + var member = source.GetMemberFromUserID(command.UserID); + if (member != null && source.RemoveMemberLocked(member)) source.PublishMemberRemoval(member); + } + destination.PublishMemberAddition(session, added.Member); + return LobbyMembershipStatus.Success; + } + finally + { + for (int index = acquired - 1; index >= 0; --index) locked[index].m_LobbyGate.Release(); + } + } + + internal Task TryMarkCompleteIfEmptyAsync(long? expectedOwner = null, bool setupOnly = false) => + RunExclusiveAsync(() => + { + if ((expectedOwner.HasValue && Owner != expectedOwner.Value) || (setupOnly && State != ELobbyState.GAME_SETUP) + || GetNumberOfHumans() != 0) return Task.FromResult(false); + State = ELobbyState.COMPLETE; + DirtyRetransmitLobbyList(); + return Task.FromResult(true); + }); + + internal Task AddCreatorAsync(UserSession session, string displayName, ushort preferredPort, UserLobbyPreferences preferences) => + RunExclusiveAsync(() => + { + var added = AddMemberLocked(session, displayName, preferredPort, true, preferences); + if (added.Member != null) PublishMemberAddition(session, added.Member); + return Task.FromResult(added.Status); + }); + + private (LobbyMembershipStatus Status, LobbyMember? Member) AddMemberLocked(UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap, UserLobbyPreferences lobbyPrefs) { - // NOTE: AddMember runs inside the per-lobby gate, so timing + slot determination can no - // longer result in two concurrent joins picking the same slot. - return await RunExclusiveAsync(async () => + if (State != ELobbyState.GAME_SETUP) return (LobbyMembershipStatus.NotInSetup, null); + if (GetMemberFromUserID(playerSession.m_UserID) != null) return (LobbyMembershipStatus.AlreadyMember, null); + + int openSlot = Array.FindIndex(Members, member => member.SlotState == EPlayerType.SLOT_OPEN); + if (openSlot < 0) return (LobbyMembershipStatus.NoOpenSlot, null); + ushort slotIndex = (ushort)openSlot; + + // QuickMatch applies social eligibility during matchmaking. + if (LobbyType == ELobbyType.CustomGame) { - if (State != ELobbyState.GAME_SETUP) + var ownerData = WebSocketManager.GetSharedDataForUser(Owner); + if (ownerData != null) { - return false; + var social = ownerData.GetSocialContainer(); + if (social.Blocked.Contains(playerSession.m_UserID)) return (LobbyMembershipStatus.BlockedByOwner, null); + if (LobbyJoinability == ELobbyJoinability.FriendsOnly && !social.Friends.Contains(playerSession.m_UserID)) + return (LobbyMembershipStatus.FriendshipRequired, null); } + } - // NOTE: this must be inside the lock, otherwise two concurrent joins for the same user can both pass - // the check and end up occupying two slots - LobbyMember? existingMember = GetMemberFromUserID(playerSession.m_UserID); - if (existingMember != null) // we're already in this lobby - { - return false; - } - - // find first open slot - bool bFoundSlot = false; - UInt16 slotIndex = 0; - foreach (var memberEntry in Members) - { - if (memberEntry.SlotState == EPlayerType.SLOT_OPEN) - { - // found a gap, use this slot index - bFoundSlot = true; - break; - } - ++slotIndex; - } - - if (!bFoundSlot) - { - return false; - } - - // Check social requirements (dont allow blocked in, and check friends only) - // SOCIAL: If the lobby owner has source user blocked, remove the lobby - // NOTE: Only check this for custom match, quick match checks it during matchmaking bucket stage - if (LobbyType == ELobbyType.CustomGame) - { - SharedUserData? lobbyOwnerSharedData = WebSocketManager.GetSharedDataForUser(Owner); // owner must be a game client - - if (lobbyOwnerSharedData != null) - { - // dont allow join if blocked - if (lobbyOwnerSharedData.GetSocialContainer().Blocked.Contains(playerSession.m_UserID)) - { - return false; - } - - // check joinability - if (LobbyJoinability == ELobbyJoinability.FriendsOnly) - { - // If it's friends only, return false if they aren't friends - if (!lobbyOwnerSharedData.GetSocialContainer().Friends.Contains(playerSession.m_UserID)) - { - return false; - } - } - } - } - - // de dupe names - string strOriginalDisplayName = strDisplayName; + string strOriginalDisplayName = strDisplayName; int dupesSeen = 0; string strNameLower = strDisplayName.ToLower(); foreach (var memberEntry in Members) @@ -1219,6 +1179,8 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa } Members[slotIndex] = newMember; + ++m_MembershipVersion; + InvalidateMeshCheck("membership_changed"); TimeMemberLeft[playerSession.m_UserID] = DateTime.UnixEpoch; // Lobby members leave public-room presence. @@ -1226,6 +1188,11 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa playerSession.UpdateSessionLobbyID(LobbyID); + return (LobbyMembershipStatus.Success, newMember); + } + + private void PublishMemberAddition(UserSession playerSession, LobbyMember newMember) + { // START NETWORK SIGNALLING // send all existing player to new player and vice-versa foreach (LobbyMember memberEntry in Members) @@ -1260,7 +1227,7 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa existingPlayerMsg.msg_id = (int)EWebSocketMessageID.NETWORK_CONNECTION_START_SIGNALLING; existingPlayerMsg.lobby_id = LobbyID; existingPlayerMsg.user_id = playerSession.m_UserID; - existingPlayerMsg.preferred_port = userPreferredPort; + existingPlayerMsg.preferred_port = newMember.Port; existingPlayerMsg.middleware_id = playerSession.GetMiddlewareID(); remoteSession.QueueWebsocketSend(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(existingPlayerMsg))); @@ -1279,9 +1246,8 @@ public async Task AddMember(UserSession playerSession, string strDisplayNa // Notify lobby members and room browsers. DirtyRetransmitLobbyList(); - Console.WriteLine("User {0} joined lobby {1}: {2} (Slot was {3})", playerSession.m_UserID, LobbyID, true, slotIndex); - return true; - }); + Console.WriteLine("User {0} joined lobby {1}: {2} (Slot was {3})", playerSession.m_UserID, LobbyID, true, newMember.SlotIndex); + } public void SendPeerTeardownToDepartingMember(LobbyMember departingMember) @@ -1315,36 +1281,60 @@ public void SendPeerTeardownToDepartingMember(LobbyMember departingMember) } } - public async Task RemoveMember(LobbyMember member) + internal async Task RemoveMemberAsync(long userID, LobbyMember? expectedMember = null) { - // Matchmaking cancellation and the client's explicit lobby leave can arrive concurrently. - // Claim the slot once, and run host migration in the same critical section as the slot - // clear, so teardown, host migration, and destruction callbacks stay atomic and idempotent - // (previously DoHostMigration ran after this gate was released, so it could interleave - // with a concurrent AddMember/RemoveMember and observe a half-updated Members/Owner). - bool bRemoved = await RunExclusiveAsync(() => + LobbyMember? removed = await RunExclusiveAsync(() => { - if (member.SlotIndex < 0 - || member.SlotIndex >= Members.Length - || !ReferenceEquals(Members[member.SlotIndex], member)) - { - return Task.FromResult(false); - } + var member = GetMemberFromUserID(userID); + return Task.FromResult(member != null && (expectedMember == null || ReferenceEquals(member, expectedMember)) + && RemoveMemberLocked(member) ? member : null); + }); + if (removed != null) PublishMemberRemoval(removed); + return removed; + } - LobbyMember placeholderMember = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, EPlayerType.SLOT_OPEN, member.SlotIndex, true); - Members[member.SlotIndex] = placeholderMember; - TimeMemberLeft[member.UserID] = DateTime.UtcNow; - - OnAfterPlayerLeftLocked(member.UserID); - - return Task.FromResult(true); - }); + internal async Task KickMemberAsync(KickLobbyMemberCommand command) + { + LobbyMember? removed = null; + var status = await RunExclusiveAsync(() => + { + if (GetMemberFromUserID(command.ActorUserID) == null) + return Task.FromResult(LobbyMembershipStatus.ActorNotMember); + if (Owner != command.ActorUserID) + return Task.FromResult(LobbyMembershipStatus.ActorNotHost); + if (command.TargetUserID == Owner) + return Task.FromResult(LobbyMembershipStatus.NoChange); + var member = command.TargetUserID.HasValue ? GetMemberFromUserID(command.TargetUserID.Value) : null; + if (member != null && RemoveMemberLocked(member)) removed = member; + return Task.FromResult(removed != null ? LobbyMembershipStatus.Success : LobbyMembershipStatus.NoChange); + }); + if (removed != null) PublishMemberRemoval(removed); + return status; + } - if (!bRemoved) + // Caller holds the lobby gate; validation and host migration form one transition. + private bool RemoveMemberLocked(LobbyMember member) + { + if (member.SlotIndex < 0 + || member.SlotIndex >= Members.Length + || !ReferenceEquals(Members[member.SlotIndex], member)) { - return; + return false; } + LobbyMember placeholderMember = new LobbyMember(this, null, -1, String.Empty, String.Empty, 0, -1, -1, -1, EPlayerType.SLOT_OPEN, member.SlotIndex, true); + Members[member.SlotIndex] = placeholderMember; + TimeMemberLeft[member.UserID] = DateTime.UtcNow; + + OnAfterPlayerLeftLocked(member.UserID); + ++m_MembershipVersion; + InvalidateMeshCheck("membership_changed"); + + return true; + } + + private void PublishMemberRemoval(LobbyMember member) + { // TODO_LOBBY: Optimize this Int64 UserID = member.UserID; Console.WriteLine("User {0} left lobby {1}", UserID, LobbyID); @@ -1390,6 +1380,15 @@ public async Task RemoveMember(LobbyMember member) } DirtyRetransmitLobbyList(); + } + + private LobbyMembershipStatus ValidateJoin(JoinLobbyCommand command) + { + if (LobbyType == ELobbyType.QuickMatch && !MatchedUserIDs.Contains(command.UserID)) + return LobbyMembershipStatus.NotMatched; + if (IsPassworded && command.Password != Password) + return LobbyMembershipStatus.PasswordMismatch; + return command.AnticheatID == AnticheatID ? LobbyMembershipStatus.Success : LobbyMembershipStatus.AnticheatMismatch; } public int GetNumberOfHumans() @@ -1838,7 +1837,7 @@ public enum EKnownAnticheatID } - public class LobbyManager + public partial class LobbyManager { private ConcurrentDictionary m_dictLobbies = new(); @@ -1856,9 +1855,11 @@ internal Int64 GenerateNextLobbyID() private readonly IServiceProvider _services; - public LobbyManager(IServiceProvider services) + public LobbyManager(IServiceProvider services, IDbContextFactory membershipDbFactory, LobbyMembershipConnections membershipConnections) { _services = services; + this.membershipDbFactory = membershipDbFactory; + this.membershipConnections = membershipConnections; } public async Task Cleanup() @@ -1882,7 +1883,7 @@ public async Task Cleanup() foreach (Lobby lobbyToRemove in lstLobbiesToRemove) { // remove it, also commit it + update leaderboard - await DeleteLobby(lobbyToRemove); + if (await lobbyToRemove.TryMarkCompleteIfEmptyAsync()) await DeleteLobby(lobbyToRemove); } } @@ -1907,7 +1908,7 @@ public async Task ProcessLobbiesNeedingDestroyed() try { - await DeleteLobby(lobbyToDestroy); + if (await lobbyToDestroy.TryMarkCompleteIfEmptyAsync()) await DeleteLobby(lobbyToDestroy); } catch (Exception ex) { @@ -1921,12 +1922,14 @@ public async Task CreateLobby(AppDbContext _db, UserSession owningSession UInt16 hostPreferredPort, bool bVanillaTeams, bool bTrackStats, UInt32 default_starting_cash, bool bPassworded, String strPassword, Int16 parentNetworkRoom, bool bAllowObservers, UInt16 maxCamHeight, UInt32 exe_crc, UInt32 ini_crc, ELobbyType lobbyType, EKnownAnticheatID anticheatID) { + using var membership = await AcquireMembershipGateAsync(owningSession.m_UserID); + if (!IsCurrentSession(owningSession) || owningSession.IsRetired) return -1; Console.WriteLine("Created lobby"); // cant own two lobbies at once, unless in gameplay - await CleanupUserLobbiesNotStarted(owningSession.m_UserID); + await CleanupUserLobbiesNotStartedAsync(owningSession.m_UserID); Console.WriteLine("[Source 3] User {0} Leave Any Lobby", owningSession.m_UserID); - await this.LeaveAnyLobby(owningSession.m_UserID); + await LeaveMembershipsAsync(owningSession.m_UserID); int rng_seed = new Random().Next(); @@ -1954,7 +1957,9 @@ public async Task CreateLobby(AppDbContext _db, UserSession owningSession if (lobbyType != ELobbyType.QuickMatch) // QuickMatch clients join after allocation. { - await JoinLobby(_db, newLobby, owningSession, strOwnerDisplayName, hostPreferredPort, true); + var preferences = await Database.Users.GetUserLobbyPreferences(_db, owningSession.m_UserID); + if (preferences != null) + await newLobby.AddCreatorAsync(owningSession, strOwnerDisplayName, hostPreferredPort, preferences); } // Send initial lobby state and invalidate room listings. @@ -1971,36 +1976,17 @@ public async Task Tick() } } - public async Task JoinLobby(AppDbContext _db, Lobby lobby, UserSession playerSession, string strDisplayName, UInt16 userPreferredPort, bool bHasMap) - { - UserLobbyPreferences? lobbyPrefs = await Database.Users.GetUserLobbyPreferences(_db, playerSession.m_UserID); - - if (lobbyPrefs != null) - { - bool bAdded = await lobby.AddMember(playerSession, strDisplayName, userPreferredPort, bHasMap, lobbyPrefs); - return bAdded; - } - - return false; - } - public int GetNumLobbies() { return m_dictLobbies.Count; } - public async Task CleanupUserLobbiesNotStarted(Int64 UserID) - { - List ownedLobbies = GetPlayerOwnedLobbies(UserID); - foreach (Lobby ownedLobby in ownedLobbies) - { - if (ownedLobby.State == ELobbyState.GAME_SETUP) // only those in setup, in game games can continue - { - await DeleteLobby(ownedLobby); - } - } - } - + private async Task CleanupUserLobbiesNotStartedAsync(Int64 userID) + { + foreach (var lobby in GetPlayerOwnedLobbies(userID)) + if (await lobby.TryMarkCompleteIfEmptyAsync(userID, setupOnly: true)) await DeleteLobby(lobby); + } + public List GetAllLobbies(Int16 selectedRoomID, bool bIncludePassword, bool bAllowInSetup, bool bAllowInGame, bool bAllowCompleted, bool bIncludeAllNetworkRooms) { List listLobbies = new List(); @@ -2126,33 +2112,6 @@ public List GetPlayerOwnedLobbies(Int64 userID) return lstLobbies; } - public async Task LeaveSpecificLobby(Int64 userID, Int64 lobbyID) - { - Lobby? targetLobby = GetLobby(lobbyID); - if (targetLobby != null) - { - LobbyMember? memberEntry = targetLobby.GetMemberFromUserID(userID); - if (memberEntry != null) - { - Console.WriteLine("User {0} Leave Specific Lobby", userID); - await targetLobby.RemoveMember(memberEntry); - } - } - } - - public async Task LeaveAnyLobby(Int64 userID) - { - foreach (Lobby lobbyInst in m_dictLobbies.Values) - { - LobbyMember? member = lobbyInst.GetMemberFromUserID(userID); - if (member != null) - { - Console.WriteLine("User {0} Leave Any Lobby", userID); - await lobbyInst.RemoveMember(member); - } - } - } - public async Task DeleteLobby(Lobby lobby) { try diff --git a/GenOnlineService/LobbyMembershipConnections.cs b/GenOnlineService/LobbyMembershipConnections.cs new file mode 100644 index 0000000..eeece37 --- /dev/null +++ b/GenOnlineService/LobbyMembershipConnections.cs @@ -0,0 +1,44 @@ +/* +** GeneralsOnline Game Services - Backend Services for Command & Conquer Generals Online: Zero Hour +** Copyright (C) 2025 GeneralsOnline Development Team +** +** This program is free software: you can redistribute it and/or modify +** it under the terms of the GNU Affero General Public License as +** published by the Free Software Foundation, either version 3 of the +** License, or (at your option) any later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU Affero General Public License for more details. +** +** You should have received a copy of the GNU Affero General Public License +** along with this program. If not, see . +*/ + +namespace GenOnlineService; + +// Adapter for the legacy session and relay registries used by membership commands. +public sealed class LobbyMembershipConnections +{ + public UserSession? GetSession(long userID, EUserSessionType sessionType) => + WebSocketManager.GetSessionFromUser(userID, sessionType); + + public Task DeleteRelayCredentialsAsync(long userID) => + TURNCredentialManager.DeleteCredentialsForUser(userID); + + public async Task<(string Username, string Token)> CreateRelayCredentialsAsync(long userID) + { + try + { + var credentials = await TURNCredentialManager.CreateCredentialsForUser(userID); + return (credentials?.m_strUsername ?? "", credentials?.m_strToken ?? ""); + } + catch (Exception ex) + { + // Relay failure does not undo a successful lobby join. + SentrySdk.CaptureException(ex); + return ("", ""); + } + } +} diff --git a/GenOnlineService/MatchmakingManager.cs b/GenOnlineService/MatchmakingManager.cs index 95d02e3..8344764 100644 --- a/GenOnlineService/MatchmakingManager.cs +++ b/GenOnlineService/MatchmakingManager.cs @@ -25,7 +25,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using System; -using System.Collections.Concurrent; +using System.Collections.Concurrent; +using System.Collections.Immutable; using System.Net; using System.Net.WebSockets; using System.Security.Claims; @@ -332,7 +333,7 @@ public bool IsLockedForMatchStart() { lock (m_StateLock) { - return m_bHasStartedCountdown || m_bWaitingOnLobbyJoins || m_bWaitingOnMeshConnectivityChecks || m_bPendingDeletion; + return m_bCreatingLobby || m_bHasStartedCountdown || m_bWaitingOnLobbyJoins || m_bWaitingOnMeshConnectivityChecks || m_bPendingDeletion; } } @@ -380,120 +381,100 @@ private TimeSpan TimeSinceLastEloExpansion() return null; } - public void DetermineMap(out string strMapName, out string strMapPath) - { - // If they are in this bucket, they had SOME map overlap with the bucket creator, now we need to find the common ground between everyone - - // TODO_QUICKMATCH: what if we cant find a suitable map? - - // first condense the map list doesn to a list that has mutually agreed upon maps/preferences from all participants - //var mapSetFromBucketCreator = new HashSet(lstMapIndices); - - var perPlayerMapSet = new List>(); - var finalMapSet = new HashSet(lstMapIndices.ToList()); // we need to check intersection against this, so pre-populate it with the original bucket creation list, because that's the "biggest set" in theory - - // TODO_QUICKMATCH: Optimize this, it's inefficient - - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) - { - perPlayerMapSet.Add(new HashSet(memberSession.MatchmakingMapIndicies.ToList())); - } - } - - // Find shared values across all of perPlayerMapSet - if (perPlayerMapSet.Count > 0) - { - for (int i = 0; i < perPlayerMapSet.Count; i++) - { - finalMapSet.IntersectWith(perPlayerMapSet[i]); - } - } - - // remove any maps that aren't big enough (mainly applies to FFA's where maps may be 6 players but bucket could be 8 players) - // NOTE: iterate a real copy, we are mutating finalMapSet below - var copyMapSetForIter = new HashSet(finalMapSet); - foreach (int mapIndex in copyMapSetForIter) - { - if (MatchmakingManager.g_Playlists.TryGetValue(PlaylistID, out Playlist? playlist)) - { - if (mapIndex >= 0 && mapIndex < playlist.Maps.Count) - { - if (playlist.Maps[mapIndex].MaxPlayers < CurrentMemberCount()) - { - finalMapSet.Remove(mapIndex); - } - } - } - } - - // Randomly select a map from the map list - if (finalMapSet.Count > 0) - { - // Get the playlist for this bucket - if (MatchmakingManager.g_Playlists.TryGetValue(PlaylistID, out Playlist? playlist)) + private sealed record Formation(ImmutableArray Players, PlaylistMap Map, bool UsedFallbackMap); + private Formation? m_Formation; + private bool m_bCreatingLobby; + private static long s_NextBucketID; + private readonly long m_BucketID = Interlocked.Increment(ref s_NextBucketID); + + private Formation? TryBeginFormation(Playlist playlist, bool graceExpired) + { + lock (m_StateLock) + { + if (IsLockedForMatchStart() || m_bMergedAway) return null; + var players = m_lstMembers.ToList().Select(member => member.GetAssociatedSession()).ToArray(); + if (players.Length < MinPlayers || players.Length > DesiredPlayers + || (!graceExpired && players.Length < DesiredPlayers) + || players.Any(player => player == null || player.IsRetired || !player.IsRegisteredForMatchmaking)) return null; + + var selectedPlayers = players.Cast().ToArray(); + var sharedMaps = new HashSet(lstMapIndices.ToList()); + foreach (var player in selectedPlayers) + sharedMaps.IntersectWith(player.MatchmakingMapIndicies.ToList()); + var exactMaps = playlist.Maps.Where(map => map.MaxPlayers == selectedPlayers.Length).ToArray(); + var preferredMaps = sharedMaps.Where(index => index >= 0 && index < playlist.Maps.Count) + .Select(index => playlist.Maps[index]).Where(map => map.MaxPlayers == selectedPlayers.Length).ToArray(); + var choices = preferredMaps.Length > 0 ? preferredMaps : exactMaps; + if (choices.Length == 0) return null; + + m_Formation = new Formation(selectedPlayers.ToImmutableArray(), choices[Random.Shared.Next(choices.Length)], preferredMaps.Length == 0); + m_bCreatingLobby = true; + m_bReachedMinPlayers = false; + m_timeReachedMinPlayers = -1; + return m_Formation; + } + } + + private bool IsFormationCurrent(Lobby? lobby = null) + { + lock (m_StateLock) + { + return m_Formation != null && !m_bAutoStartInvalidated && !m_bPendingDeletion + && CurrentMemberCount() == m_Formation.Players.Length + && m_Formation.Players.All(player => !player.IsRetired && player.IsRegisteredForMatchmaking && HasPlayer(player)) + && (lobby == null || (lobby.NumCurrentPlayers == m_Formation.Players.Length + && m_Formation.Players.All(player => lobby.GetMemberFromUserID(player.m_UserID)?.GetSession() + .TryGetTarget(out var memberSession) == true && ReferenceEquals(memberSession, player)))); + } + } + + private async Task CreateFormationLobbyAsync(Playlist playlist, Formation formation) + { + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + try + { + using var scope = ServiceLocator.Services.CreateScope(); + var factory = scope.ServiceProvider.GetRequiredService>(); + await using var db = await factory.CreateDbContextAsync(); + foreach (var player in formation.Players) { - var finalMapIndices = finalMapSet.ToList(); - int selectedIndex = Random.Shared.Next(finalMapIndices.Count); - int mapIndex = finalMapIndices[selectedIndex]; - - // Defensive: ensure index is valid for playlist.Maps - if (mapIndex >= 0 && mapIndex < playlist.Maps.Count) - { - strMapName = playlist.Maps[mapIndex].Name; - strMapPath = playlist.Maps[mapIndex].Path; - return; - } + await Database.Leaderboards.CreateUserEntriesIfNotExists(db, player.m_UserID); + await SendMatchmakingMessage(player, $"Creating a {formation.Players.Length}-player QuickMatch on {formation.Map.Name}." + + (formation.Players.Length < playlist.DesiredPlayers ? " Starting below the playlist maximum after the grace period." : "") + + (formation.UsedFallbackMap ? " No shared map preference fits this size; using an exact-size playlist map." : "")); + } + var host = formation.Players[0]; + var hostData = GenOnlineService.WebSocketManager.GetSharedDataForUser(host.m_UserID); + if (hostData == null) throw new InvalidOperationException("QuickMatch host session disappeared."); + if (!IsFormationCurrent()) throw new InvalidOperationException("QuickMatch formation changed."); + m_LobbyID = await lobbyManager.CreateLobby(db, host, hostData.m_strDisplayName, "Quickmatch Lobby", formation.Map.Name, formation.Map.Path + ".map", + true, formation.Players.Length, "", 12345, false, true, 10000, false, String.Empty, -5, false, Constants.g_DefaultCameraMaxHeight, + ExeCRC, IniCRC, ELobbyType.QuickMatch, AnticheatID); + var lobby = lobbyManager.GetLobby(m_LobbyID) ?? throw new InvalidOperationException("QuickMatch lobby creation failed."); + foreach (var player in formation.Players) lobby.MatchedUserIDs.Add(player.m_UserID); + var action = new WebSocketMessage_MatchmakerJoinLobby + { + msg_id = (int)EWebSocketMessageID.MATCHMAKING_ACTION_JOIN_PREARRANGED_LOBBY, + lobby_id = m_LobbyID + }; + var payload = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(action)); + lock (m_StateLock) + { + if (!IsFormationCurrent()) throw new InvalidOperationException("QuickMatch formation changed."); + m_bWaitingOnLobbyJoins = true; + m_timeStartedWaitingOnLobbyJoins = Environment.TickCount64; + foreach (var player in formation.Players) player.QueueWebsocketSend(payload); } } - else + catch (Exception ex) { - // pick a sensible default (biggest map in playlist), probably not what the players asked for, but we cant play on no map - Console.WriteLine("WARNING: No mutually agreed upon map found for matchmaking bucket, falling back to largest map in playlist"); - - if (MatchmakingManager.g_Playlists.TryGetValue(PlaylistID, out Playlist? playlist)) - { - int biggestCountSeen = 0; - PlaylistMap? mapToUse = null; - foreach (var map in playlist.Maps) - { - if (map.MaxPlayers > biggestCountSeen) - { - biggestCountSeen = map.MaxPlayers; - mapToUse = map; - } - } - - // TODO_QUICKMATCH: What if it's still null? don't think we can get into that state since we must have some kind of map in the playlist - if (mapToUse != null) - { - strMapName = mapToUse.Name; - strMapPath = mapToUse.Path; - return; - } - } + Console.WriteLine($"QuickMatch formation failed: {ex}"); + await AbortQuickMatchAutoStartAsync("QuickMatch setup failed or a player left during formation."); } - - // TODO_QUICKMATCH: What happens if you widen when already in a bucket? tell the user htey cant? you would need everyone to expand, or just expand for everyone? - - // Fallback: use first map from bucket creator's list if available - if (lstMapIndices.Count > 0 && MatchmakingManager.g_Playlists.TryGetValue(PlaylistID, out Playlist? fallbackPlaylist)) + finally { - int fallbackIndex = lstMapIndices[0]; - if (fallbackIndex >= 0 && fallbackIndex < fallbackPlaylist.Maps.Count) - { - strMapName = fallbackPlaylist.Maps[fallbackIndex].Name; - strMapPath = fallbackPlaylist.Maps[fallbackIndex].Path; - return; - } + lock (m_StateLock) m_bCreatingLobby = false; } - - // If no map found, set to empty - strMapName = string.Empty; - strMapPath = string.Empty; - } public bool DoMapSelectionsIntersect(ConcurrentList lstRhs) @@ -580,28 +561,23 @@ public bool CanMergeWithOtherBucket(MatchmakingBucket bucketToMerge) return true; } - public async Task MergeWithOtherBucket(MatchmakingBucket bucketToMerge) + public async Task MergeWithOtherBucketAsync(MatchmakingBucket bucketToMerge) { - // copy over players (skipping anyone already present, and dead sessions) - foreach (MatchmakingBucketMember rhsMember in bucketToMerge.m_lstMembers) - { - UserSession? rhsSession = rhsMember.GetAssociatedSession(); - if (rhsSession == null || HasPlayer(rhsSession)) + if (ReferenceEquals(this, bucketToMerge)) return false; + var first = m_BucketID < bucketToMerge.m_BucketID ? this : bucketToMerge; + var second = ReferenceEquals(first, this) ? bucketToMerge : this; + lock (first.m_StateLock) + lock (second.m_StateLock) + { + if (!CanMergeWithOtherBucket(bucketToMerge)) return false; + foreach (var member in bucketToMerge.m_lstMembers) { - continue; + var session = member.GetAssociatedSession(); + if (session != null && !session.IsRetired && !HasPlayer(session)) m_lstMembers.Add(member); } - - this.m_lstMembers.Add(rhsMember); - } - - // the source bucket is now empty and flagged so it can never merge/accept players again - lock (bucketToMerge.m_StateLock) - { - bucketToMerge.m_bMergedAway = true; + bucketToMerge.m_bMergedAway = true; + bucketToMerge.m_lstMembers.Clear(); } - bucketToMerge.m_lstMembers.Clear(); - - // nothing else to copy... everything else should match since we were a merge candidate // tell all players foreach (MatchmakingBucketMember member in m_lstMembers) @@ -612,6 +588,7 @@ public async Task MergeWithOtherBucket(MatchmakingBucket bucketToMerge) await SendMatchmakingMessage(session, String.Format("Your matchmaking bucket was merged with another bucket. Status: {0}/{1} players. ({2} required to start)", CurrentMemberCount(), DesiredPlayers, MinPlayers)); } } + return true; } public bool HasPlayer(UserSession playerSession) @@ -642,7 +619,7 @@ public bool RemovePlayer(UserSession playerSession, out bool bCancellationReject return false; } - if (m_bWaitingOnLobbyJoins || m_bHasStartedCountdown || m_bWaitingOnMeshConnectivityChecks) + if (m_bCreatingLobby || m_bWaitingOnLobbyJoins || m_bHasStartedCountdown || m_bWaitingOnMeshConnectivityChecks) { m_bAutoStartInvalidated = true; } @@ -672,7 +649,7 @@ public bool PruneDeadMembers() { if (member.GetAssociatedSession() == null) { - if (m_bWaitingOnLobbyJoins || m_bHasStartedCountdown || m_bWaitingOnMeshConnectivityChecks) + if (m_bCreatingLobby || m_bWaitingOnLobbyJoins || m_bHasStartedCountdown || m_bWaitingOnMeshConnectivityChecks) { m_bAutoStartInvalidated = true; bInvalidatedSetup = true; @@ -803,36 +780,22 @@ public int GetAvgElo() } - public async Task Join(UserSession playerSession) + public async Task JoinAsync(UserSession playerSession) { - // already in here? nothing to do (and never add a duplicate member) - if (HasPlayer(playerSession)) + lock (m_StateLock) { - return true; + if (HasPlayer(playerSession)) return true; + if (playerSession.IsRetired || IsJoiningUserBlockedByOrHasBlockedAnyBucketMember(playerSession, playerSession.m_UserID) + || !HasSpaceForUsers(1, playerSession.ExeCRC, playerSession.IniCRC, playerSession.AnticheatID)) return false; + m_lstMembers.Add(new MatchmakingBucketMember(playerSession)); } - - // cant be blocked by others in this bucket - if (!IsJoiningUserBlockedByOrHasBlockedAnyBucketMember(playerSession, playerSession.m_UserID)) - { - if (HasSpaceForUsers(1, playerSession.ExeCRC, playerSession.IniCRC, playerSession.AnticheatID)) - { - m_lstMembers.Add(new MatchmakingBucketMember(playerSession)); - - // tell everyone - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) - { - await SendMatchmakingMessage(memberSession, String.Format("Status: {0}/{1} players. ({2} required to start)", CurrentMemberCount(), DesiredPlayers, MinPlayers)); - } - } - - return true; - } - } - - return false; + foreach (var member in m_lstMembers) + { + var session = member.GetAssociatedSession(); + if (session != null) + await SendMatchmakingMessage(session, $"Status: {CurrentMemberCount()}/{DesiredPlayers} players. ({MinPlayers} required to start)"); + } + return true; } public MatchmakingBucket(UInt16 playlistID, UserSession owningSession, int minPlayers, int desiredPlayers, ConcurrentList mapIndices, UInt32 exe_crc, UInt32 ini_crc, EKnownAnticheatID anticheatID) @@ -858,12 +821,12 @@ public Int64 GetLobbyID() Int64 m_timeStartedWaitingOnLobbyJoins = -1; // how long we give everyone to actually connect to the QuickMatch lobby before we give up on the stragglers - private const Int64 c_LobbyJoinTimeoutMSec = 45000; - private const int c_GameStartCountdownMSec = 5000; - private const int c_SetupClientTimeoutMarginMSec = 2000; + private const Int64 LobbyJoinTimeoutMs = 45000; + private const int GameStartCountdownMs = 5000; + private const int SetupClientTimeoutMarginMs = 2000; // older clients treat any timeout_ms below this as the start countdown - private const int c_LegacyClientCountdownThresholdMSec = 10000; + private const int LegacyClientCountdownThresholdMs = 10000; private async Task StartGameAfterSuccessfulMeshCheck(Lobby lobby) { @@ -905,9 +868,11 @@ private void QueueSetupProgress(int timeoutMSec, int countdownMSec = 0) } } + private long m_MeshCheckID; + private async Task TriggerFullMeshConnectivityChecks(Lobby lobby) { - await lobby.StartFullMeshConnectivityCheck(); + m_MeshCheckID = await lobby.StartMeshCheckAsync(); lock (m_StateLock) { // Publish the state transition before any notification work. If a send fails, the @@ -916,9 +881,7 @@ private async Task TriggerFullMeshConnectivityChecks(Lobby lobby) } // kept at or above the legacy threshold so short tuned checks don't show a start countdown on older clients - QueueSetupProgress(Math.Max(FullMeshCheckSettings.MaxDurationMS + c_SetupClientTimeoutMarginMSec, c_LegacyClientCountdownThresholdMSec)); - - lobby.SendFullMeshConnectivityCheckRequestToMembers(); + QueueSetupProgress(Math.Max(FullMeshCheckSettings.MaxDurationMs + SetupClientTimeoutMarginMs, LegacyClientCountdownThresholdMs)); foreach (MatchmakingBucketMember member in m_lstMembers) { @@ -930,9 +893,10 @@ private async Task TriggerFullMeshConnectivityChecks(Lobby lobby) } } - private async Task AbortQuickMatchAutoStart(string reason) + private async Task AbortQuickMatchAutoStartAsync(string reason, bool dropNonJoiners = false) { List sessionsToRequeue = new(); + long abortedLobbyID; lock (m_StateLock) { if (m_bAbortInProgress || m_bStartCommitted) @@ -941,6 +905,7 @@ private async Task AbortQuickMatchAutoStart(string reason) } m_bAbortInProgress = true; + abortedLobbyID = m_LobbyID; m_bPendingDeletion = true; foreach (MatchmakingBucketMember member in m_lstMembers) @@ -959,9 +924,33 @@ private async Task AbortQuickMatchAutoStart(string reason) } LobbyManager lobbyManager = ServiceLocator.Services.GetRequiredService(); - Lobby? quickMatchLobby = lobbyManager.GetLobby(m_LobbyID); + Lobby? quickMatchLobby = lobbyManager.GetLobby(abortedLobbyID); if (quickMatchLobby != null) { + if (dropNonJoiners) + { + // Close admission before classifying late arrivals; never change a newer registration. + await quickMatchLobby.UpdateState(ELobbyState.COMPLETE); + foreach (var session in sessionsToRequeue) + { + if (quickMatchLobby.GetMemberFromUserID(session.m_UserID) != null) continue; + bool dropped = false; + await session.MatchmakingStateLock.WaitAsync(); + try + { + lock (m_StateLock) + { + if (HasPlayer(session)) + { + session.IsRegisteredForMatchmaking = false; + dropped = true; + } + } + } + finally { session.MatchmakingStateLock.Release(); } + if (dropped) await SendMatchmakingMessage(session, "You failed to join the QuickMatch lobby in time and have been removed from matchmaking."); + } + } foreach (UserSession memberSession in sessionsToRequeue) { LobbyMember? lobbyMember = quickMatchLobby.GetMemberFromUserID(memberSession.m_UserID); @@ -970,8 +959,7 @@ private async Task AbortQuickMatchAutoStart(string reason) // TODO: Remove this fallback once all supported clients handle MATCHMAKING_ACTION_REQUEUE. // Legacy clients do not understand the requeue action, but they can still tear down // peer and anti-cheat connections before joining the next temporary lobby. - quickMatchLobby.SendPeerTeardownToDepartingMember(lobbyMember); - await quickMatchLobby.RemoveMember(lobbyMember); + await lobbyManager.RemoveMatchmakingMemberAsync(quickMatchLobby, lobbyMember, sendPeerTeardown: true); } } @@ -984,7 +972,7 @@ private async Task AbortQuickMatchAutoStart(string reason) foreach (UserSession memberSession in sessionsToRequeue) { - if (await TryRequeueRegisteredPlayer(memberSession, requeueActionJSON)) + if (await lobbyManager.TryRequeueMatchmakingAsync(memberSession, abortedLobbyID, requeueActionJSON)) { await SendMatchmakingMessage(memberSession, reason); await SendMatchmakingMessage(memberSession, "Re-queueing you into matchmaking..."); @@ -1006,6 +994,7 @@ public async Task Tick() bool bHasStartedCountdown; lock (m_StateLock) { + if (m_bCreatingLobby) return; bPendingDeletion = m_bPendingDeletion; bAutoStartInvalidated = m_bAutoStartInvalidated; bWaitingOnMeshConnectivityChecks = m_bWaitingOnMeshConnectivityChecks; @@ -1040,7 +1029,7 @@ public async Task Tick() if (bAutoStartInvalidated) { - await AbortQuickMatchAutoStart("QuickMatch auto-start was aborted because a player left during match setup."); + await AbortQuickMatchAutoStartAsync("QuickMatch auto-start was aborted because a player left during match setup."); return; } @@ -1049,13 +1038,13 @@ public async Task Tick() Lobby? lobbyDuringMeshCheck = lobbyManager.GetLobby(m_LobbyID); if (lobbyDuringMeshCheck == null) { - await AbortQuickMatchAutoStart("QuickMatch auto-start was aborted because the temporary lobby no longer exists."); + await AbortQuickMatchAutoStartAsync("QuickMatch auto-start was aborted because the temporary lobby no longer exists."); return; } await lobbyDuringMeshCheck.ProcessPendingFullMeshConnectivityChecks(); - if (!lobbyDuringMeshCheck.PendingFullMeshConnectivityChecks) + if (await lobbyDuringMeshCheck.GetValidatedMeshCheckResultAsync(m_MeshCheckID) is bool meshComplete) { bool bStartCountdown; bool bAbortStart; @@ -1071,11 +1060,11 @@ public async Task Tick() else { m_bWaitingOnMeshConnectivityChecks = false; - bStartCountdown = !bInvalidatedAtDecision && lobbyDuringMeshCheck.LastFullMeshConnectivityCheckOutcome == true; + bStartCountdown = IsFormationCurrent(lobbyDuringMeshCheck) && meshComplete; bAbortStart = !bStartCountdown; if (bStartCountdown) { - m_StartTime = Environment.TickCount64 + c_GameStartCountdownMSec; + m_StartTime = Environment.TickCount64 + GameStartCountdownMs; m_bHasStartedCountdown = true; } } @@ -1083,13 +1072,13 @@ public async Task Tick() if (bStartCountdown) { - QueueSetupProgress(c_GameStartCountdownMSec + c_SetupClientTimeoutMarginMSec, c_GameStartCountdownMSec); + QueueSetupProgress(GameStartCountdownMs + SetupClientTimeoutMarginMs, GameStartCountdownMs); foreach (MatchmakingBucketMember member in m_lstMembers) { UserSession? memberSession = member.GetAssociatedSession(); if (memberSession != null) { - await SendMatchmakingMessage(memberSession, $"Starting game in {c_GameStartCountdownMSec / 1000} seconds."); + await SendMatchmakingMessage(memberSession, $"Starting game in {GameStartCountdownMs / 1000} seconds."); } } } @@ -1098,7 +1087,7 @@ public async Task Tick() string reason = bInvalidatedAtDecision ? "QuickMatch auto-start was aborted because a player left during match setup." : "QuickMatch auto-start was aborted because not all players were fully mesh-connected."; - await AbortQuickMatchAutoStart(reason); + await AbortQuickMatchAutoStartAsync(reason); } } @@ -1115,10 +1104,11 @@ public async Task Tick() Lobby? lobbyAfterCountdown = lobbyManager.GetLobby(m_LobbyID); if (lobbyAfterCountdown == null) { - await AbortQuickMatchAutoStart("QuickMatch auto-start was aborted because the temporary lobby no longer exists."); + await AbortQuickMatchAutoStartAsync("QuickMatch auto-start was aborted because the temporary lobby no longer exists."); return; } + bool meshStillValid = await lobbyAfterCountdown.GetValidatedMeshCheckResultAsync(m_MeshCheckID) == true; bool bStartGame; bool bAbortStart; lock (m_StateLock) @@ -1132,7 +1122,7 @@ public async Task Tick() { m_bHasStartedCountdown = false; m_StartTime = -1; - bStartGame = !m_bAutoStartInvalidated; + bStartGame = meshStillValid && IsFormationCurrent(lobbyAfterCountdown); bAbortStart = !bStartGame; if (bStartGame) { @@ -1148,7 +1138,7 @@ public async Task Tick() } else if (bAbortStart) { - await AbortQuickMatchAutoStart("QuickMatch auto-start was aborted because a player left during match setup."); + await AbortQuickMatchAutoStartAsync("QuickMatch auto-start was aborted because a player left during match setup."); } return; @@ -1214,88 +1204,8 @@ await SendMatchmakingMessage(memberSession, // did we hit the timer OR have enough players to start? bool bMinPlayersCountdownExpired = m_bReachedMinPlayers && (Environment.TickCount64 - m_timeReachedMinPlayers) > playlist.GracePeriodAtMinPlayersMSec; - if (bMinPlayersCountdownExpired || CurrentMemberCount() >= DesiredPlayers) - { - // reset min player countdown - m_bReachedMinPlayers = false; - m_timeReachedMinPlayers = -1; - - lock (m_StateLock) - { - m_bWaitingOnLobbyJoins = true; - m_timeStartedWaitingOnLobbyJoins = Environment.TickCount64; - } - - // tell everyone - UserSession? dummyHostUser = null; - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) - { - // create lb data if necessary - using var scope = ServiceLocator.Services.CreateScope(); - var factory = scope.ServiceProvider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - - await Database.Leaderboards.CreateUserEntriesIfNotExists(db, memberSession.m_UserID); - - if (dummyHostUser == null) - { - dummyHostUser = memberSession; - } - await SendMatchmakingMessage(memberSession, "Creating a QuickMatch lobby for everyone..."); - } - } - - // should have a user by now - if (dummyHostUser != null) - { - SharedUserData? dummyHostUserData = GenOnlineService.WebSocketManager.GetSharedDataForUser(dummyHostUser.m_UserID); - - if (dummyHostUserData != null) - { - // make a lobby - DetermineMap(out string strMapName, out string strMapPath); - - using var scope = ServiceLocator.Services.CreateScope(); - var factory = scope.ServiceProvider.GetRequiredService>(); - await using var db = await factory.CreateDbContextAsync(); - - m_LobbyID = await lobbyManager.CreateLobby(db, dummyHostUser, dummyHostUserData.m_strDisplayName, "Quickmatch Lobby", strMapName, strMapPath + ".map", - true, playlist.DesiredPlayers, "", 12345, false, true, 10000, false, String.Empty, -5, false, Constants.g_DefaultCameraMaxHeight, dummyHostUser.ExeCRC, dummyHostUser.IniCRC, ELobbyType.QuickMatch, - dummyHostUser.AnticheatID); - - Lobby? quickMatchLobby = lobbyManager.GetLobby(m_LobbyID); - if (quickMatchLobby != null) - { - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) - { - quickMatchLobby.MatchedUserIDs.Add(memberSession.m_UserID); - } - } - } - - // tell both to join our lobby - WebSocketMessage_MatchmakerJoinLobby joinAction = new WebSocketMessage_MatchmakerJoinLobby(); - joinAction.msg_id = (int)EWebSocketMessageID.MATCHMAKING_ACTION_JOIN_PREARRANGED_LOBBY; - joinAction.lobby_id = m_LobbyID; - byte[] bytesJSON = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(joinAction)); - - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) - { - memberSession.QueueWebsocketSend(bytesJSON); - } - } - } - } - } + var formation = TryBeginFormation(playlist, bMinPlayersCountdownExpired); + if (formation != null) await CreateFormationLobbyAsync(playlist, formation); } else { @@ -1359,50 +1269,16 @@ await SendMatchmakingMessage(memberSession, // has someone failed to connect? drop them so the rest of the players aren't stuck here forever bool bJoinTimeoutExpired = m_timeStartedWaitingOnLobbyJoins != -1 - && (Environment.TickCount64 - m_timeStartedWaitingOnLobbyJoins) > c_LobbyJoinTimeoutMSec; + && (Environment.TickCount64 - m_timeStartedWaitingOnLobbyJoins) > LobbyJoinTimeoutMs; - if (bJoinTimeoutExpired && lobby.NumCurrentPlayers != CurrentMemberCount()) + if (bJoinTimeoutExpired && !IsFormationCurrent(lobby)) { - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession == null || lobby.GetMemberFromUserID(memberSession.m_UserID) == null) - { - if (memberSession != null) - { - Console.WriteLine("User {0} failed to join QuickMatch lobby {1} in time, dropping from bucket", memberSession.m_UserID, m_LobbyID); - memberSession.IsRegisteredForMatchmaking = false; - await SendMatchmakingMessage(memberSession, "You failed to join the QuickMatch lobby in time and have been removed from matchmaking."); - } - - m_lstMembers.Remove(member); - } - } - - // not enough players survived? tear the whole thing down and let people re-queue - if (CurrentMemberCount() < MinPlayers) - { - foreach (MatchmakingBucketMember member in m_lstMembers) - { - UserSession? memberSession = member.GetAssociatedSession(); - if (memberSession != null) - { - memberSession.IsRegisteredForMatchmaking = false; - await SendMatchmakingMessage(memberSession, "Not enough players joined the QuickMatch lobby. Please try matchmaking again."); - } - } - - m_bWaitingOnLobbyJoins = false; - m_timeStartedWaitingOnLobbyJoins = -1; - - await lobbyManager.DeleteLobby(lobby); - MatchmakingManager.DestroyBucket(this); - return; - } + await AbortQuickMatchAutoStartAsync("Not all selected players joined the QuickMatch lobby in time.", dropNonJoiners: true); + return; } { - if (lobby.NumCurrentPlayers == CurrentMemberCount()) // everyone is in, lets start for real + if (IsFormationCurrent(lobby)) { m_timeStartedWaitingOnLobbyJoins = -1; @@ -1450,6 +1326,26 @@ await SendMatchmakingMessage(memberSession, } // Using ConcurrentBag instead of ConcurrentList for lock-free bucket management private static ConcurrentDictionary> m_dictMatchmakingBuckets = new(); + + public static void RemoveInvalidPlaylistsAtStartup() + { + foreach (var (id, playlist) in g_Playlists.ToArray()) + { + string? error = null; + if (playlist.MinPlayers < 2 || playlist.DesiredPlayers > Lobby.MaxLobbySize || playlist.MinPlayers > playlist.DesiredPlayers) + error = "player bounds exceed supported lobby capacity"; + else if (playlist.MinSelectedMaps < 1 || playlist.MinSelectedMaps > playlist.Maps.Count) + error = "minimum map selection is outside the playlist map count"; + else if (playlist.Maps.Any(map => map.MaxPlayers < playlist.MinPlayers || map.MaxPlayers > playlist.DesiredPlayers)) + error = "a map capacity is outside playlist player bounds"; + else if (Enumerable.Range(playlist.MinPlayers, playlist.DesiredPlayers - playlist.MinPlayers + 1) + .Any(size => !playlist.Maps.Any(map => map.MaxPlayers == size))) + error = "an allowed formation size has no exact-size map"; + if (error == null) continue; + Console.WriteLine($"Disabling matchmaking playlist {id} ({playlist.Name}): {error}."); + g_Playlists.Remove(id); + } + } // TODO_QUICKMATCH: Read from db or file private static Dictionary g_Playlists = new() @@ -1616,9 +1512,8 @@ public static async Task Tick() { if (mmBucket.CanMergeWithOtherBucket(mmBucketMergeCandidate)) { - await mmBucket.MergeWithOtherBucket(mmBucketMergeCandidate); - - lstBucketsMergedNeedingDeleted.Add(mmBucketMergeCandidate); + if (await mmBucket.MergeWithOtherBucketAsync(mmBucketMergeCandidate)) + lstBucketsMergedNeedingDeleted.Add(mmBucketMergeCandidate); } } } @@ -1659,7 +1554,7 @@ public static async Task Tick() { // A cancellation can remove the weak reference while this tick is iterating a snapshot. // Re-check registration while holding the per-session gate before assigning any bucket. - if (!thisSession.IsRegisteredForMatchmaking || !IsPendingSession(thisSession)) + if (thisSession.IsRetired || !thisSession.IsRegisteredForMatchmaking || !IsPendingSession(thisSession)) { lstDestroy.Add(wrSession); continue; @@ -1739,7 +1634,7 @@ public static async Task Tick() // do the maps overlap? if so we can join if (mmBucket.DoMapSelectionsIntersect(thisSession.MatchmakingMapIndicies)) { - bool bJoined = await mmBucket.Join(thisSession); + bool bJoined = await mmBucket.JoinAsync(thisSession); if (bJoined) { @@ -1821,12 +1716,12 @@ private static void RemovePendingSession(UserSession session) } } - private static async Task TryRequeueRegisteredPlayer(UserSession session, byte[] requeueActionJSON) + internal static async Task TryRequeueRegisteredPlayer(UserSession session, byte[] requeueActionJSON) { await session.MatchmakingStateLock.WaitAsync(); try { - if (!session.IsRegisteredForMatchmaking) + if (session.IsRetired || !session.IsRegisteredForMatchmaking) { return false; } @@ -1836,7 +1731,6 @@ private static async Task TryRequeueRegisteredPlayer(UserSession session, lstSessions.Add(new WeakReference(session)); } - session.UpdateSessionLobbyID(-1); session.QueueWebsocketSend(requeueActionJSON); return true; } @@ -1874,16 +1768,16 @@ public static async Task RegisterPlayer(UserSession plr, UInt16 playlistID, List return; } - bool bCancellationRejected; + MatchmakingDetachment detached; await plr.MatchmakingStateLock.WaitAsync(); try { - // A duplicate registration must not leave the player queued twice or in two buckets. + if (plr.IsRetired) return; plr.IsRegisteredForMatchmaking = false; RemovePendingSession(plr); - bCancellationRejected = await RemovePlayerFromAllBuckets(plr); + detached = RemovePlayerFromAllBuckets(plr); - if (!bCancellationRejected) + if (!detached.CancellationRejected) { plr.MatchmakingPlaylistID = playlistID; plr.MatchmakingMapIndicies = new ConcurrentList(validatedMapIndices); @@ -1899,7 +1793,11 @@ public static async Task RegisterPlayer(UserSession plr, UInt16 playlistID, List plr.MatchmakingStateLock.Release(); } - if (bCancellationRejected) + var lobbyManager = ServiceLocator.Services.GetRequiredService(); + foreach (var entry in detached.Members) + await lobbyManager.RemoveMatchmakingMemberAsync(entry.Lobby, entry.Member); + + if (detached.CancellationRejected) { await SendMatchmakingMessage(plr, "Matchmaking cannot be restarted because your game is already starting."); return; @@ -1908,66 +1806,47 @@ public static async Task RegisterPlayer(UserSession plr, UInt16 playlistID, List await SendMatchmakingMessage(plr, "Started matchmaking... Searching for players..."); } - private static async Task RemovePlayerFromAllBuckets(UserSession plr) - { - var lobbyManager = ServiceLocator.Services.GetRequiredService(); - bool bCancellationRejected = false; - - // also remove from any bucket we are in to avoid ghost buckets - foreach (var kvPair in m_dictMatchmakingBuckets) - { - foreach (MatchmakingBucket mmBucket in kvPair.Value) - { - bool bRemoved = mmBucket.RemovePlayer(plr, out bool bBucketCancellationRejected); - bCancellationRejected |= bBucketCancellationRejected; - - if (bRemoved) - { - // remove from QM lobby too - Lobby? lobby = lobbyManager.GetLobby(mmBucket.GetLobbyID()); - if (lobby != null) - { - LobbyMember? lobbyMember = lobby.GetMemberFromUserID(plr.m_UserID); - if (lobbyMember != null) - { - Console.WriteLine("User {0} Leave MM Lobby", plr.m_UserID); - await lobby.RemoveMember(lobbyMember); - } - } - - // if we're the last player, destroy the bucket - if (mmBucket.CurrentMemberCount() == 0) - { - DestroyBucket(mmBucket); - } - } - } - } - - return bCancellationRejected; - } - - public static async Task DeregisterPlayer(UserSession plr) - { - var lobbyManager = ServiceLocator.Services.GetRequiredService(); - bool bCancellationRejected; - await plr.MatchmakingStateLock.WaitAsync(); - try - { - plr.IsRegisteredForMatchmaking = false; - RemovePendingSession(plr); - bCancellationRejected = await RemovePlayerFromAllBuckets(plr); - } - finally - { - plr.MatchmakingStateLock.Release(); - } - - // leave QM lobby too - if (!bCancellationRejected) - { - Console.WriteLine("[Source 4] User {0} Leave Any Lobby", plr.m_UserID); - await lobbyManager.LeaveAnyLobby(plr.m_UserID); - } - } + internal sealed record MatchmakingDetachment(bool CancellationRejected, List<(Lobby Lobby, LobbyMember Member)> Members); + + private static MatchmakingDetachment RemovePlayerFromAllBuckets(UserSession player) + { + var lobbies = ServiceLocator.Services.GetRequiredService(); + bool rejected = false; + List<(Lobby, LobbyMember)> members = new(); + foreach (var buckets in m_dictMatchmakingBuckets.Values) + { + foreach (var bucket in buckets) + { + bool removed = bucket.RemovePlayer(player, out bool cancellationRejected); + rejected |= cancellationRejected; + if (!removed) continue; + var lobby = lobbies.GetLobby(bucket.GetLobbyID()); + var member = lobby?.GetMemberFromUserID(player.m_UserID); + if (member != null && member.GetSession().TryGetTarget(out var memberSession) && ReferenceEquals(memberSession, player)) + members.Add((lobby!, member)); + if (bucket.CurrentMemberCount() == 0) DestroyBucket(bucket); + } + } + return new(rejected, members); + } + + // Only matchmaking state is changed here. Membership effects run after releasing + // this lock, so lifecycle operations can acquire user gate -> matchmaking lock. + internal static async Task DetachSessionAsync(UserSession player) + { + await player.MatchmakingStateLock.WaitAsync(); + try + { + player.IsRegisteredForMatchmaking = false; + RemovePendingSession(player); + return RemovePlayerFromAllBuckets(player); + } + finally + { + player.MatchmakingStateLock.Release(); + } + } + + public static Task DeregisterPlayerAsync(UserSession player) => + ServiceLocator.Services.GetRequiredService().CancelMatchmakingAsync(player); } diff --git a/GenOnlineService/Program.cs b/GenOnlineService/Program.cs index 7de732d..07f761d 100644 --- a/GenOnlineService/Program.cs +++ b/GenOnlineService/Program.cs @@ -1169,6 +1169,7 @@ public static async Task Main(string[] args) var builder = WebApplication.CreateBuilder(args); RoomCatalog.Initialize(Path.Combine(builder.Environment.ContentRootPath, "data", "rooms.json")); + MatchmakingManager.RemoveInvalidPlaylistsAtStartup(); // Add services to the container. @@ -1244,6 +1245,7 @@ public static async Task Main(string[] args) builder.Services.AddHostedService(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); var rateLimitingSettings = Program.g_Config.GetSection("RateLimiting"); bool bUseBuiltinRateLimiter = rateLimitingSettings.GetValue("use_builtin_ratelimiter"); // use built in Kestrel/dotnet rate limiting if you do not have a reverse proxy or other rate limiter in front of service